# Misprint Hero (`misprint-hero`)

> A hero headline printed as three colour-separation plates that fly in misregistered and spring into register — the cursor pulls the print slightly out of true again.

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

## AI prompt

Add a MisprintHero component from the mellow library — a hero whose headline is printed as three colour-separation plates that fly in misregistered and spring into register, drifting out of true again as the cursor moves. Pass `headline`, optional `kicker` and `subheadline`, and a CTA row as `children`. Tune `height` and `drift`.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `headline` | `string` | — | The headline printed in three colour-separation plates. |
| `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` | `520` | Hero height in px. |
| `drift` | `number` | `7` | How far the plates drift apart as the cursor moves, in px. |
| `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, { useEffect, useRef, useState } from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

export interface MisprintHeroProps {
  /** The headline printed in three colour-separation plates. */
  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;
  /** How far the plates drift apart as the cursor moves, in px. */
  drift?: number;
  className?: string;
  style?: React.CSSProperties;
}

// colour separations: CMY multiplied on paper, RGB screened on night
const LIGHT_PLATES = ["oklch(0.72 0.14 220)", "oklch(0.62 0.24 350)", "oklch(0.84 0.17 95)"];
const DARK_PLATES = ["oklch(0.6 0.22 25)", "oklch(0.68 0.18 150)", "oklch(0.58 0.21 265)"];

/** True when the page background is light — re-checks on theme change. */
function useIsLight() {
  const [light, setLight] = useState(false);
  useEffect(() => {
    const read = () => {
      const raw = getComputedStyle(document.documentElement)
        .getPropertyValue("--background-rgb")
        .split(",")
        .map((n) => parseFloat(n));
      if (raw.length >= 3 && raw.every((n) => !isNaN(n))) {
        setLight((raw[0] * 0.299 + raw[1] * 0.587 + raw[2] * 0.114) / 255 > 0.5);
      }
    };
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });
    return () => mo.disconnect();
  }, []);
  return light;
}

/**
 * A hero headline printed as three colour-separation plates that fly in
 * misregistered and spring into register — the cursor pulls the print
 * slightly out of true again.
 */
export function MisprintHero({
  headline,
  kicker,
  subheadline,
  children,
  height = 520,
  drift = 7,
  className,
  style,
}: MisprintHeroProps) {
  const reduced = useReducedMotion();
  const light = useIsLight();
  const ref = useRef<HTMLDivElement>(null);

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

  const plates = light ? LIGHT_PLATES : DARK_PLATES;
  const blend = light ? "multiply" : "screen";
  // entry offsets per plate — wildly out of register
  const entry = [
    { x: -70, y: 34, r: -3 },
    { x: 58, y: -26, r: 2 },
    { x: -18, y: -52, r: 4 },
  ];
  // residual misregistration once settled — real presses never hit true
  const rest = [
    { x: -1.5, y: 1 },
    { x: 1.5, y: -0.5 },
    { x: 0, y: -1.5 },
  ];
  const factors = [-1, 0.6, 1.3];

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

  return (
    <section
      ref={ref}
      onPointerMove={onMove}
      onPointerLeave={onLeave}
      className={["relative flex w-full flex-col items-center justify-center overflow-hidden px-6 text-center", className]
        .filter(Boolean)
        .join(" ")}
      style={{ height, ...style }}
    >
      {kicker && (
        <motion.p
          initial={reduced ? false : { opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, delay: 0.5 }}
          className="mb-5 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.24em] text-[rgba(var(--ink-rgb),0.5)] uppercase"
        >
          {kicker}
        </motion.p>
      )}
      <h1 className="relative [font-family:var(--font-serif)] text-[clamp(2.75rem,8vw,5.5rem)] leading-[0.95] font-medium tracking-tight italic">
        <span className="sr-only">{headline}</span>
        {/* sizer keeps layout; plates stack on top of it */}
        <span aria-hidden="true" className="invisible">
          {headline}
        </span>
        {entry.map((e, i) => (
          <Plate
            key={i}
            text={headline}
            color={plates[i]}
            blend={blend}
            entry={e}
            rest={rest[i]}
            factor={factors[i]}
            delay={0.12 * i}
            sx={sx}
            sy={sy}
            reduced={!!reduced}
          />
        ))}
      </h1>
      {subheadline && (
        <motion.p
          initial={reduced ? false : { opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.6, delay: 0.65 }}
          className="mt-6 max-w-md text-[0.9375rem] leading-relaxed text-[rgba(var(--ink-rgb),0.6)]"
        >
          {subheadline}
        </motion.p>
      )}
      {children && (
        <motion.div
          initial={reduced ? false : { opacity: 0, y: 12 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.6, delay: 0.8 }}
          className="mt-8 flex items-center gap-4"
        >
          {children}
        </motion.div>
      )}
    </section>
  );
}

function Plate({
  text,
  color,
  blend,
  entry,
  rest,
  factor,
  delay,
  sx,
  sy,
  reduced,
}: {
  text: string;
  color: string;
  blend: string;
  entry: { x: number; y: number; r: number };
  rest: { x: number; y: number };
  factor: number;
  delay: number;
  sx: MotionValue<number>;
  sy: MotionValue<number>;
  reduced: boolean;
}) {
  const dx = useTransform(sx, (n) => n * factor);
  const dy = useTransform(sy, (n) => n * factor);
  return (
    <motion.span
      aria-hidden="true"
      initial={reduced ? false : { x: entry.x, y: entry.y, rotate: entry.r, opacity: 0 }}
      animate={{ x: rest.x, y: rest.y, rotate: 0, opacity: 1 }}
      transition={
        reduced
          ? { duration: 0 }
          : { type: "spring", stiffness: 110, damping: 15, delay }
      }
      className="absolute inset-0"
      style={{ color, mixBlendMode: blend as React.CSSProperties["mixBlendMode"] }}
    >
      <motion.span className="block" style={reduced ? undefined : { x: dx, y: dy }}>
        {text}
      </motion.span>
    </motion.span>
  );
}

export default MisprintHero;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { MisprintHero } from "../mellow/misprint-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 MisprintHeroDemo() {
  const [run, setRun] = useState(0);
  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">
        <MisprintHero
          key={run}
          kicker="Mellow Press — Est. 2026"
          headline="Ink, off the press"
          subheadline="Three colour plates fly in out of register and settle into print. Move the cursor to pull them out of true."
          height={narrow ? 260 : 430}
        >
          <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)]"
          >
            See the catalogue
          </button>
          <button
            type="button"
            className="cursor-pointer px-2 py-2 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.55)] uppercase transition-colors hover:text-[var(--ink)]"
          >
            About the press
          </button>
        </MisprintHero>
      </div>
      <div className="flex items-center gap-4">
        <button
          type="button"
          onClick={() => setRun((r) => r + 1)}
          className="cursor-pointer border border-[var(--rule)] px-2.5 py-1 [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.06)]"
        >
          Print again
        </button>
        <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
          Move the cursor to shift the plates
        </p>
      </div>
    </div>
  );
}

```
