# Woodblock Hero (`woodblock-hero`)

> A woodblock-print sunrise hero — vermillion sun rising behind layered indigo hills, paper-coloured mist between the ridges, and gentle depth parallax as the cursor moves.

- **Docs:** https://www.mellowui.com/components/woodblock-hero
- **Markdown:** https://www.mellowui.com/components/woodblock-hero.md
- **Registry:** https://www.mellowui.com/r/woodblock-hero.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/woodblock-hero
- **Categories:** display, svg, animation
- **Dependencies:** motion

## AI prompt

Add a WoodblockHero component from the mellow library — a woodblock-print sunrise hero with a vermillion sun behind layered indigo ridges and cursor parallax. Pass `headline`, optional `kicker` and `subheadline`, and a CTA row as `children`. Tune `height` and `parallax` (0 disables).

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/woodblock-hero.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `headline` | `string` | — | Main headline set over the sky. |
| `kicker` | `string` | — | Small mono line above the headline. |
| `subheadline` | `string` | — | Supporting line under the headline. |
| `children` | `React.ReactNode` | — | CTA row rendered under the subheadline. |
| `height` | `number` | `560` | Hero height in px. |
| `parallax` | `number` | `1` | Parallax strength multiplier — 0 disables. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useId, useRef } from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

export interface WoodblockHeroProps {
  /** Main headline set over the sky. */
  headline: string;
  /** Small mono line above the headline. */
  kicker?: string;
  /** Supporting line under the headline. */
  subheadline?: string;
  /** CTA row rendered under the subheadline. */
  children?: React.ReactNode;
  /** Hero height in px. */
  height?: number;
  /** Parallax strength multiplier — 0 disables. */
  parallax?: number;
  className?: string;
  style?: React.CSSProperties;
}

// woodblock ink palette — fixed spot colours, printed on the theme's paper
const SUN = "oklch(0.63 0.2 35)";
const HILLS = [
  "oklch(0.66 0.05 230)",
  "oklch(0.55 0.07 240)",
  "oklch(0.42 0.08 250)",
  "oklch(0.3 0.07 255)",
];
const HILL_PATHS = [
  "M0 400 Q 150 330 300 380 T 620 360 Q 780 300 900 370 T 1200 350 L1200 640 L0 640 Z",
  "M0 460 Q 180 380 360 440 T 700 430 Q 880 370 1030 440 T 1200 430 L1200 640 L0 640 Z",
  "M0 520 Q 220 440 430 505 T 820 500 Q 980 450 1200 510 L1200 640 L0 640 Z",
  "M0 580 Q 300 520 600 570 T 1200 565 L1200 640 L0 640 Z",
];
const DEPTHS = [4, 9, 15, 24];

/**
 * A woodblock-print sunrise hero — vermillion sun rising behind layered
 * indigo hills, paper-coloured mist between the ridges, and gentle depth
 * parallax as the cursor moves.
 */
export function WoodblockHero({
  headline,
  kicker,
  subheadline,
  children,
  height = 560,
  parallax = 1,
  className,
  style,
}: WoodblockHeroProps) {
  const reduced = useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");

  const mx = useMotionValue(0);
  const my = useMotionValue(0);
  const sx = useSpring(mx, { stiffness: 50, damping: 20 });
  const sy = useSpring(my, { stiffness: 50, damping: 20 });

  const onMove = (e: React.PointerEvent) => {
    if (reduced || !parallax || !ref.current) return;
    const r = ref.current.getBoundingClientRect();
    mx.set(((e.clientX - r.left) / r.width - 0.5) * 2 * parallax);
    my.set(((e.clientY - r.top) / r.height - 0.5) * 2 * parallax);
  };
  const onLeave = () => {
    mx.set(0);
    my.set(0);
  };

  return (
    <section
      ref={ref}
      onPointerMove={onMove}
      onPointerLeave={onLeave}
      className={["relative w-full overflow-hidden", className].filter(Boolean).join(" ")}
      style={{ height, ...style }}
    >
      <svg
        aria-hidden="true"
        className="absolute inset-0 h-full w-full"
        viewBox="0 0 1200 640"
        preserveAspectRatio="xMidYMax slice"
      >
        <defs>
          <radialGradient id={`glow-${uid}`} cx="0.5" cy="0.5" r="0.5">
            <stop offset="0%" stopColor={SUN} stopOpacity="0.35" />
            <stop offset="100%" stopColor={SUN} stopOpacity="0" />
          </radialGradient>
          <filter id={`grain-${uid}`}>
            <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" />
            <feColorMatrix type="saturate" values="0" />
            <feComponentTransfer>
              <feFuncA type="linear" slope="0.5" />
            </feComponentTransfer>
            <feComposite operator="in" in2="SourceGraphic" />
          </filter>
          <filter id={`mist-${uid}`} x="-20%" y="-120%" width="140%" height="340%">
            <feGaussianBlur stdDeviation="14" />
          </filter>
        </defs>

        {/* rising sun with glow */}
        <Layer x={sx} y={sy} depth={2} reduced={!!reduced}>
          <motion.g
            initial={reduced ? false : { y: 190, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            transition={reduced ? { duration: 0 } : { duration: 2.2, ease: [0.22, 0.9, 0.3, 1] }}
          >
            <circle cx="880" cy="320" r="240" fill={`url(#glow-${uid})`} />
            <circle cx="880" cy="320" r="82" fill={SUN} />
          </motion.g>
        </Layer>

        {/* drifting birds */}
        {!reduced && (
          <motion.g
            initial={{ x: 0 }}
            animate={{ x: -380 }}
            transition={{ duration: 46, ease: "linear", repeat: Infinity, repeatType: "mirror" }}
            fill="none"
            stroke="rgba(var(--ink-rgb),0.55)"
            strokeWidth="2.5"
            strokeLinecap="round"
          >
            <path d="M 620 190 q 7 -8 14 0 q 7 -8 14 0" />
            <path d="M 680 158 q 6 -7 12 0 q 6 -7 12 0" />
            <path d="M 560 150 q 5 -6 10 0 q 5 -6 10 0" />
          </motion.g>
        )}

        {/* hills, far to near, mist between the ridges */}
        {HILL_PATHS.map((d, i) => (
          <React.Fragment key={i}>
            <Layer x={sx} y={sy} depth={DEPTHS[i]} reduced={!!reduced}>
              <path d={d} fill={HILLS[i]} />
            </Layer>
            {i < 2 && (
              <rect
                x="-40"
                y={415 + i * 88}
                width="1280"
                height="34"
                fill="var(--background)"
                opacity="0.45"
                filter={`url(#mist-${uid})`}
              />
            )}
          </React.Fragment>
        ))}

        {/* paper grain */}
        <rect
          x="0"
          y="0"
          width="1200"
          height="640"
          filter={`url(#grain-${uid})`}
          opacity="0.05"
        />
      </svg>

      <div className="relative z-10 flex h-full flex-col items-start justify-start p-8 pt-14 sm:p-12 sm:pt-16">
        {kicker && (
          <motion.p
            initial={reduced ? false : { opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.6, delay: 0.4 }}
            className="mb-4 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.24em] text-[rgba(var(--ink-rgb),0.55)] uppercase"
          >
            {kicker}
          </motion.p>
        )}
        <motion.h1
          initial={reduced ? false : { opacity: 0, y: 16 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.8, delay: 0.55 }}
          className="max-w-xl [font-family:var(--font-serif)] text-[clamp(2.25rem,5.5vw,4rem)] leading-[1.02] font-medium tracking-tight text-[var(--ink)] italic"
        >
          {headline}
        </motion.h1>
        {subheadline && (
          <motion.p
            initial={reduced ? false : { opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.7, delay: 0.75 }}
            className="mt-5 max-w-sm text-[0.9375rem] leading-relaxed text-[rgba(var(--ink-rgb),0.65)]"
          >
            {subheadline}
          </motion.p>
        )}
        {children && (
          <motion.div
            initial={reduced ? false : { opacity: 0, y: 12 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.7, delay: 0.9 }}
            className="mt-7 flex items-center gap-4"
          >
            {children}
          </motion.div>
        )}
      </div>
    </section>
  );
}

/** Parallax wrapper — shifts its children against the cursor by depth. */
function Layer({
  x,
  y,
  depth,
  reduced,
  children,
}: {
  x: MotionValue<number>;
  y: MotionValue<number>;
  depth: number;
  reduced: boolean;
  children: React.ReactNode;
}) {
  const dx = useTransform(x, (n) => n * -depth);
  const dy = useTransform(y, (n) => n * -depth * 0.5);
  if (reduced) return <g>{children}</g>;
  return <motion.g style={{ x: dx, y: dy }}>{children}</motion.g>;
}

export default WoodblockHero;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { WoodblockHero } from "../mellow/woodblock-hero";


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function WoodblockHeroDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex w-full flex-col items-center gap-2 p-2 sm:gap-4 sm:p-4">
      <div className="w-full overflow-hidden rounded-lg border border-[var(--rule)]">
        <WoodblockHero
          kicker="Field Notes — Vol. II"
          headline="The mountains keep their own hours"
          subheadline="A vermillion sun climbs behind indigo ridges, printed layer by layer on the page."
          height={narrow ? 280 : 480}
        >
          <button
            type="button"
            className="cursor-pointer border border-[var(--rule)] bg-[rgba(var(--ink-rgb),0.06)] px-4 py-2 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase transition-colors hover:bg-[rgba(var(--ink-rgb),0.12)]"
          >
            Read the journal
          </button>
        </WoodblockHero>
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Move the cursor — the ridges sit at different depths
      </p>
    </div>
  );
}

```
