# Stencil — Animated Text Effect for React

> Canvas headline spray-painted through a cut stencil plate: seeded S-curve sweeps deposit grainy paint inside the letter cutouts with faint overspray and a few drips, then the plate lifts away to reveal the word; drag to add coats.

- Page: https://crucibleui.com/components/text/stencil
- Category: [text](https://crucibleui.com/components/text)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/stencil
```

Registry JSON: https://crucibleui.com/r/stencil.json

Import from `@/components/crucible/stencil`.

## Props (`StencilProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text?` | `string` | `"STENCIL"` | The headline (also rendered as a visually hidden heading for screen readers + SEO). |
| `as?` | `"h1" \| "h2" \| "h3" \| "p"` | `"h2"` | Element the visually hidden real text is rendered as, so it stays a heading. |
| `paint?` | `string` | `"#f2f0eb" (off-white)` | Spray paint color. |
| `plate?` | `string` | `"#2a2b2f" (graphite)` | Stencil plate color — keep it a little lighter than the surface behind it. |
| `fontSize?` | `number` | `auto (~48% of the height)` | Maximum glyph size in px; the headline scales down (and wraps to two lines) to fit. |
| `fontFamily?` | `string` | `a bold condensed sans stack (Impact / Oswald / Arial Narrow…)` | Canvas font stack. Any counters (the holes in O, A, B…) get stencil bridges cut automatically, so plain bold fonts read as real stencils. Web fonts are picked up once `document.fonts` is ready. |
| `passes?` | `number` | `3` | Number of S-curve sweeps the can makes per line of text. 1–8. |
| `drips?` | `number` | `5` | Paint runs that trickle 4–18px down from the letters' bottom edges during the intro. 0–16. |
| `seed?` | `number` | `7` | Deterministic seed for the spray grain, sweep paths and drip spots. |
| `ref?` | `React.Ref<StencilHandle>` | — | Imperative handle: `replay()`. |

Also accepts: `Omit< React.HTMLAttributes<HTMLDivElement>, "children" | "color" >`.

## Usage example

```tsx
"use client";

import * as React from "react";

import {
  Stencil,
  type StencilHandle,
} from "@/components/crucible/stencil";

export default function StencilDemo() {
  const ref = React.useRef<StencilHandle>(null);
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex w-full max-w-3xl flex-col items-center gap-4 px-6 py-8">
        <Stencil
          ref={ref}
          text="FRESH PAINT"
          as="h2"
          className="h-60 min-h-0 w-full"
        />
        <div className="flex items-center gap-3">
          <p className="text-sm text-neutral-500">Drag to add paint.</p>
          <button
            type="button"
            onClick={() => ref.current?.replay()}
            className="rounded-full border border-white/10 px-3 py-1 text-xs text-neutral-400 transition-colors hover:border-white/20 hover:text-neutral-200 focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:outline-none"
          >
            Replay
          </button>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`stencil.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface StencilHandle {
  /** Wipe the paint, lay the plate back down and spray the headline again. */
  replay(): void;
}

export interface StencilProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children" | "color"
> {
  /** The headline (also rendered as a visually hidden heading for screen readers + SEO). @default "STENCIL" */
  text?: string;
  /** Element the visually hidden real text is rendered as, so it stays a heading. @default "h2" */
  as?: "h1" | "h2" | "h3" | "p";
  /** Spray paint color. @default "#f2f0eb" (off-white) */
  paint?: string;
  /** Stencil plate color — keep it a little lighter than the surface behind it. @default "#2a2b2f" (graphite) */
  plate?: string;
  /** Maximum glyph size in px; the headline scales down (and wraps to two lines) to fit. @default auto (~48% of the height) */
  fontSize?: number;
  /**
   * Canvas font stack. Any counters (the holes in O, A, B…) get stencil
   * bridges cut automatically, so plain bold fonts read as real stencils.
   * Web fonts are picked up once `document.fonts` is ready.
   * @default a bold condensed sans stack (Impact / Oswald / Arial Narrow…)
   */
  fontFamily?: string;
  /** Number of S-curve sweeps the can makes per line of text. 1–8. @default 3 */
  passes?: number;
  /** Paint runs that trickle 4–18px down from the letters' bottom edges during the intro. 0–16. @default 5 */
  drips?: number;
  /** Deterministic seed for the spray grain, sweep paths and drip spots. @default 7 */
  seed?: number;
  /** Imperative handle: `replay()`. */
  ref?: React.Ref<StencilHandle>;
}

const DEFAULT_FONT =
  '"Saira Stencil One", "Big Shoulders Stencil Display", Impact, Haettenschweiler, "Oswald", "Bebas Neue", "Arial Narrow Bold", "Arial Narrow", "Roboto Condensed", sans-serif';

const START = 0.3; // first sweep begins
const GAP = 0.07; // the can reverses between passes
const SETTLE = 0.4; // pause between the last pass and the plate lift
const LIFT_DUR = 1.05;
const OVERSPRAY = 0.22; // alpha of paint that creeps under the plate edge
const MAX_DRIPS = 40;

/** Deterministic mulberry32 PRNG. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const clamp = (v: number, lo: number, hi: number) =>
  Math.min(hi, Math.max(lo, v));
const smooth = (a: number, b: number, v: number) => {
  const t = clamp((v - a) / (b - a), 0, 1);
  return t * t * (3 - 2 * t);
};
const easeOut = (u: number, p = 3) => 1 - Math.pow(1 - clamp(u, 0, 1), p);

interface Drip {
  x: number;
  y: number;
  len: number;
  w: number;
  start: number;
  dur: number;
  drawn: number;
}

interface Layout {
  w: number;
  h: number;
  dpr: number;
  fs: number;
  lines: number;
  /** CSS-px glyph mask (post-bridging). */
  mask: Uint8Array;
  /** Text block bounds (CSS px). */
  box: { x0: number; y0: number; x1: number; y1: number };
  /** Plate rect (CSS px). */
  plateRect: { x: number; y: number; w: number; h: number };
  /** Candidate drip origins — points on the letters' bottom edges. */
  edges: number[];
  maskC: HTMLCanvasElement;
  padSoftC: HTMLCanvasElement;
  padHardC: HTMLCanvasElement;
  plateC: HTMLCanvasElement;
  cutShadowC: HTMLCanvasElement;
  wordC: HTMLCanvasElement;
  platePaintC: HTMLCanvasElement;
  /** Fresh grain splats this frame. */
  freshC: HTMLCanvasElement;
  /** Fresh soft base coat this frame (kept out of the overspray so the halo stays speckled). */
  coatC: HTMLCanvasElement;
  dripFreshC: HTMLCanvasElement;
  tmpC: HTMLCanvasElement;
  blob: HTMLCanvasElement;
  /** Spray cone radius for the intro sweeps (CSS px). */
  R: number;
  passDur: number;
  passCount: number;
  sprayEnd: number;
  liftAt: number;
}

/**
 * Stencil — a headline spray-painted through a cut stencil plate. The glyphs
 * are rasterized into a mask (counters get stencil bridges cut automatically),
 * a graphite plate with those letters cut out sits over the surface, and an
 * off-screen can sweeps a few seeded S-curve passes, depositing thousands of
 * soft, grainy low-alpha splats: full strength inside the cutouts, a faint
 * overspray creeping under the plate edge, and the rest landing on the plate
 * itself. A few drips run down from the letters' bottom edges, then the plate
 * lifts away — shadow growing, fading out — revealing the crisp painted word.
 * Pointer drag sprays more paint locally (coats stack; lingering makes it
 * run). DPR-clamped, pauses offscreen/hidden; the real text ships as a
 * visually hidden heading; reduced motion shows the finished painted word.
 */
export function Stencil({
  text = "STENCIL",
  as = "h2",
  paint = "#f2f0eb",
  plate = "#2a2b2f",
  fontSize,
  fontFamily = DEFAULT_FONT,
  passes = 3,
  drips = 5,
  seed = 7,
  className,
  ref,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  ...props
}: StencilProps) {
  const Tag = as;
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const reducedMotion = useReducedMotion();
  const reducedRef = React.useRef(reducedMotion);
  React.useLayoutEffect(() => {
    reducedRef.current = reducedMotion;
  }, [reducedMotion]);

  const layoutRef = React.useRef<Layout | null>(null);
  const clockRef = React.useRef(0); // seconds since the intro (re)started
  const lastElapsedRef = React.useRef(0);
  const introDoneRef = React.useRef(false);
  const dripsRef = React.useRef<Drip[]>([]);
  const randRef = React.useRef(mulberry32(seed));
  const dirtyRef = React.useRef(true);
  const dragRef = React.useRef({
    active: false,
    id: -1,
    x: 0,
    y: 0,
    lx: 0,
    ly: 0,
    dwell: 0,
  });

  const passCount = clamp(Math.round(passes), 1, 8);
  const dripCount = clamp(Math.round(drips), 0, 16);

  // ---------------------------------------------------------------- painting
  /** Bind a context for drawing in CSS px. */
  const cssCtx = (c: HTMLCanvasElement, dpr: number) => {
    const ctx = c.getContext("2d")!;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    return ctx;
  };

  /** Spray one burst of grainy splats + a soft base coat at (x, y) into the fresh layer. */
  const sprayAt = React.useCallback(
    (
      L: Layout,
      x: number,
      y: number,
      R: number,
      amount: number,
      rand: () => number,
    ) => {
      // Soft base coat — gives the coverage its smooth, misted body.
      const coat = cssCtx(L.coatC, L.dpr);
      // Jittered so repeated bursts on one spot never quantize into rings.
      coat.globalAlpha = 0.15 * Math.min(1, amount);
      const br = R * (0.85 + rand() * 0.3);
      const bx = x + (rand() - 0.5) * R * 0.35;
      const by = y + (rand() - 0.5) * R * 0.35;
      coat.drawImage(L.blob, bx - br, by - br, br * 2, br * 2);
      const ctx = cssCtx(L.freshC, L.dpr);
      // Grain — many tiny low-alpha dots in a gaussian cone.
      const n = Math.round(R * 0.75 * amount);
      const grain = clamp(L.fs / 110, 0.7, 1.6);
      ctx.fillStyle = paint;
      for (let i = 0; i < n; i++) {
        const r1 = Math.max(rand(), 1e-6);
        const mag = Math.sqrt(-2 * Math.log(r1)) * R * 0.42;
        const ang = rand() * Math.PI * 2;
        const px = x + Math.cos(ang) * mag;
        const py = y + Math.sin(ang) * mag;
        const s = grain * (0.45 + rand() * rand() * 1.9);
        ctx.globalAlpha = 0.14 + rand() * 0.42;
        if (s < 1.1) ctx.fillRect(px - s / 2, py - s / 2, s, s);
        else {
          ctx.beginPath();
          ctx.arc(px, py, s * 0.6, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      ctx.globalAlpha = 1;
    },
    [paint],
  );

  /** Point on intro sweep `i` at progress u — alternating S-curves stacked down the text block. */
  const sweepPoint = (
    L: Layout,
    i: number,
    u: number,
    rand0: number,
  ): [number, number] => {
    const { x0, x1, y0, y1 } = L.box;
    const dir = i % 2 === 0 ? 1 : -1;
    const xa = x0 - L.R * 0.8;
    const xb = x1 + L.R * 0.8;
    const x = dir > 0 ? xa + (xb - xa) * u : xb - (xb - xa) * u;
    const band = (y1 - y0) / L.passCount;
    const yc = y0 + band * (i + 0.5);
    const amp = band * 0.5 + L.fs * 0.04;
    const y = yc + Math.sin(u * Math.PI * 2 + rand0 * 1.2 - 0.6) * amp * dir;
    return [x, y];
  };

  /** Spray the stretch of sweep `i` between progress u0 and u1. */
  const sprayAlong = React.useCallback(
    (L: Layout, i: number, u0: number, u1: number) => {
      if (u1 <= u0) return;
      const r0 = mulberry32(seed * 977 + i * 131)();
      const [ax, ay] = sweepPoint(L, i, u0, r0);
      const [bx, by] = sweepPoint(L, i, u1, r0);
      const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / 3));
      for (let s = 1; s <= steps; s++) {
        const u = u0 + ((u1 - u0) * s) / steps;
        const [x, y] = sweepPoint(L, i, u, r0);
        sprayAt(L, x, y, L.R, 1, randRef.current);
      }
    },
    [sprayAt, seed],
  );

  /** Extend every drip to its length at time t, drawing the new run into the drip layer. */
  const growDrips = React.useCallback(
    (L: Layout, t: number, instant: boolean) => {
      let any = false;
      const ctx = cssCtx(L.dripFreshC, L.dpr);
      ctx.fillStyle = paint;
      for (const d of dripsRef.current) {
        const p = instant ? 1 : easeOut((t - d.start) / d.dur, 2.4);
        const target = d.len * p;
        if (target <= d.drawn + 0.05) continue;
        any = true;
        for (let l = d.drawn; l <= target; l += 0.6) {
          const k = l / d.len;
          const r = d.w * (0.42 + 0.22 * k); // thickens toward the bead
          ctx.globalAlpha = 0.9;
          ctx.beginPath();
          ctx.arc(
            d.x + Math.sin(l * 0.35 + d.x) * 0.25,
            d.y + l,
            r,
            0,
            Math.PI * 2,
          );
          ctx.fill();
        }
        d.drawn = target;
        if (p >= 1) {
          ctx.beginPath();
          ctx.arc(d.x, d.y + d.len, d.w * 0.72, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      ctx.globalAlpha = 1;
      return any;
    },
    [paint],
  );

  /** Route the fresh splats into the word (inside the cutouts + faint overspray) and onto the plate. */
  const flush = React.useCallback((L: Layout, overspray = OVERSPRAY) => {
    const tmp = L.tmpC.getContext("2d")!;
    const word = L.wordC.getContext("2d")!;
    const pp = L.platePaintC.getContext("2d")!;
    for (const c of [tmp, word, pp]) c.setTransform(1, 0, 0, 1, 0, 0);
    const pass = (
      src: HTMLCanvasElement,
      clip: HTMLCanvasElement,
      op: GlobalCompositeOperation,
    ) => {
      tmp.globalCompositeOperation = "copy";
      tmp.drawImage(src, 0, 0);
      tmp.globalCompositeOperation = op;
      tmp.drawImage(clip, 0, 0);
      tmp.globalCompositeOperation = "source-over";
    };
    // Overspray under the plate edge (soft padded mask, low alpha).
    pass(L.freshC, L.padSoftC, "destination-in");
    word.globalAlpha = overspray;
    word.drawImage(L.tmpC, 0, 0);
    word.globalAlpha = 1;
    // Full-strength paint through the cutouts.
    for (const src of [L.coatC, L.freshC]) {
      pass(src, L.maskC, "destination-in");
      word.drawImage(L.tmpC, 0, 0);
    }
    // Everything else lands on the plate.
    for (const src of [L.coatC, L.freshC]) {
      pass(src, L.plateC, "destination-in");
      pp.drawImage(L.tmpC, 0, 0);
    }
    // Drips may creep a few px past the cut edge.
    pass(L.dripFreshC, L.padHardC, "destination-in");
    word.drawImage(L.tmpC, 0, 0);
    for (const c of [L.freshC, L.coatC, L.dripFreshC]) {
      const ctx = c.getContext("2d")!;
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, c.width, c.height);
    }
  }, []);

  /** Seed the intro drips on bottom edges of the letters. */
  const planDrips = React.useCallback(
    (L: Layout) => {
      const rand = mulberry32(seed * 313 + 17);
      const out: Drip[] = [];
      const minGap = L.fs * 0.3;
      const scale = clamp(L.fs / 120, 0.75, 1.5);
      let guard = 0;
      while (out.length < dripCount && L.edges.length && guard++ < 400) {
        const k = Math.floor(rand() * (L.edges.length / 2)) * 2;
        const x = L.edges[k];
        const y = L.edges[k + 1];
        if (out.some((d) => Math.abs(d.x - x) < minGap)) continue;
        const len = (4 + rand() * 14) * scale;
        out.push({
          x,
          y: y - L.fs * (0.02 + rand() * 0.05),
          len,
          w: (2 + rand() * 1.4) * scale,
          start: L.sprayEnd - 0.55 + out.length * 0.16 + rand() * 0.25,
          dur: 0.7 + (len / (18 * scale)) * 0.9,
          drawn: 0,
        });
      }
      dripsRef.current = out;
    },
    [seed, dripCount],
  );

  const clearPaint = (L: Layout) => {
    for (const c of [L.wordC, L.platePaintC, L.freshC, L.coatC, L.dripFreshC]) {
      const ctx = c.getContext("2d")!;
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, c.width, c.height);
    }
  };

  /** Paint the finished word in one go (reduced motion, resize after the intro). */
  const paintInstant = React.useCallback(
    (L: Layout) => {
      clearPaint(L);
      randRef.current = mulberry32(seed);
      for (let i = 0; i < L.passCount; i++) sprayAlong(L, i, 0, 1);
      planDrips(L);
      growDrips(L, 0, true);
      flush(L);
    },
    [seed, sprayAlong, planDrips, growDrips, flush],
  );

  // ---------------------------------------------------------------- render
  const render = React.useCallback(() => {
    const canvas = canvasRef.current;
    const L = layoutRef.current;
    if (!canvas || !L) return;
    const ctx = canvas.getContext("2d")!;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(L.wordC, 0, 0);

    const t = clockRef.current;
    const lift =
      introDoneRef.current || reducedRef.current
        ? 1
        : clamp((t - L.liftAt) / LIFT_DUR, 0, 1);
    if (lift >= 1) return;
    // Unstick first (rise toward the viewer, shadow blooming), then pull away.
    const rise = smooth(0, 0.32, lift);
    const e = smooth(0.22, 1, lift);
    const alpha = 1 - smooth(0.35, 0.9, lift);

    // Plate thickness casts a hairline shadow into each cutout while it's down.
    ctx.globalAlpha = 1 - smooth(0, 0.25, lift);
    ctx.drawImage(L.cutShadowC, 0, 0);

    const { x, y, w, h } = L.plateRect;
    const cx = (x + w / 2) * L.dpr;
    const cy = (y + h / 2) * L.dpr;
    ctx.save();
    ctx.translate(cx + e * L.fs * 0.2, cy - e * (h * 0.6 + L.fs * 0.3));
    ctx.rotate(-0.05 * e);
    const sc = 1 + 0.025 * rise + 0.05 * e;
    ctx.scale(sc, sc);
    ctx.translate(-cx, -cy);
    ctx.globalAlpha = alpha;
    const lifted = Math.max(rise, e);
    ctx.shadowColor = `rgba(0,0,0,${0.6 * (1 - e * 0.5)})`;
    ctx.shadowBlur = (2 + 14 * rise + 22 * e) * L.dpr;
    ctx.shadowOffsetY = (1 + 8 * rise + 18 * e) * L.dpr;
    ctx.shadowOffsetX = -lifted * 6 * L.dpr;
    ctx.drawImage(L.plateC, 0, 0);
    ctx.shadowColor = "transparent";
    ctx.drawImage(L.platePaintC, 0, 0);
    ctx.restore();
    ctx.globalAlpha = 1;
  }, []);

  /** Pointer spray: interpolate along the stroke, and let paint build up (and run) where it lingers. */
  const sprayDrag = React.useCallback(
    (L: Layout, dt: number) => {
      const d = dragRef.current;
      const R = clamp(L.fs * 0.2, 14, 46);
      const dist = Math.hypot(d.x - d.lx, d.y - d.ly);
      const steps = Math.max(1, Math.ceil(dist / 4));
      const amount = clamp((dt * 60) / steps, 0.25, 1) * 0.55;
      for (let s = 1; s <= steps; s++) {
        const x = d.lx + ((d.x - d.lx) * s) / steps;
        const y = d.ly + ((d.y - d.ly) * s) / steps;
        sprayAt(L, x, y, R, amount, randRef.current);
      }
      d.lx = d.x;
      d.ly = d.y;
      if (reducedRef.current) return;
      // Lingering over a letter's bottom edge pools the paint into a fresh drip.
      d.dwell += dt * (dist < R * 0.5 ? 1 : 0.35);
      if (d.dwell > 0.75 && dripsRef.current.length < MAX_DRIPS) {
        d.dwell = 0;
        let best = -1;
        let bestD = R * 1.3;
        for (let k = 0; k < L.edges.length; k += 2) {
          const dd = Math.hypot(L.edges[k] - d.x, L.edges[k + 1] - d.y);
          if (
            dd < bestD &&
            !dripsRef.current.some((q) => Math.abs(q.x - L.edges[k]) < 4)
          ) {
            bestD = dd;
            best = k;
          }
        }
        if (best >= 0) {
          const rand = randRef.current;
          const scale = clamp(L.fs / 120, 0.75, 1.5);
          const len = (6 + rand() * 12) * scale;
          dripsRef.current.push({
            x: L.edges[best],
            y: L.edges[best + 1] - L.fs * 0.03,
            len,
            w: (1.4 + rand() * 1.2) * scale,
            start: clockRef.current,
            dur: 0.8 + (len / (18 * scale)) * 0.9,
            drawn: 0,
          });
        }
      }
    },
    [sprayAt],
  );

  // ---------------------------------------------------------------- loop
  const tick = React.useCallback(
    (elapsed: number) => {
      if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;
      const dt = Math.min(elapsed - lastElapsedRef.current, 0.05);
      lastElapsedRef.current = elapsed;
      const L = layoutRef.current;
      if (!L || dt <= 0) return;

      const drag = dragRef.current;
      let changed = false;

      if (!introDoneRef.current) {
        const t0 = clockRef.current;
        const t1 = t0 + dt;
        clockRef.current = t1;
        // Intro sweeps overlapping (t0, t1].
        for (let i = 0; i < L.passCount; i++) {
          const ps = START + i * (L.passDur + GAP);
          const a = clamp((t0 - ps) / L.passDur, 0, 1);
          const b = clamp((t1 - ps) / L.passDur, 0, 1);
          if (b > a) {
            const ease = (v: number) => 0.5 - 0.5 * Math.cos(Math.PI * v);
            sprayAlong(L, i, ease(a), ease(b));
          }
        }
        if (
          t1 >= L.liftAt + LIFT_DUR &&
          dripsRef.current.every((d) => t1 >= d.start + d.dur)
        ) {
          introDoneRef.current = true;
        }
        changed = true;
      } else {
        clockRef.current += dt;
      }

      if (growDrips(L, clockRef.current, false)) changed = true;

      if (drag.active) {
        sprayDrag(L, dt);
        changed = true;
      }

      if (!changed && !dirtyRef.current) return;
      // With the plate gone, a hand-held can mists the surround more heavily.
      flush(L, drag.active && introDoneRef.current ? OVERSPRAY * 2 : OVERSPRAY);
      render();
      dirtyRef.current = false;
    },
    [sprayAlong, growDrips, sprayDrag, flush, render],
  );

  const containerRef = useVisibilityPause<HTMLDivElement>(tick, {
    paused: reducedMotion,
  });

  // ---------------------------------------------------------------- layout
  React.useEffect(() => {
    const canvas = canvasRef.current;
    const container = containerRef.current;
    if (!canvas || !container) return;
    let cancelled = false;
    let lastW = 0;
    let lastH = 0;

    const build = (force: boolean) => {
      if (cancelled) return;
      const w = Math.max(container.clientWidth, 1);
      const h = Math.max(container.clientHeight, 1);
      if (!force && w === lastW && h === lastH) return;
      lastW = w;
      lastH = h;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const W = Math.round(w * dpr);
      const H = Math.round(h * dpr);
      canvas.width = W;
      canvas.height = H;

      const prev = layoutRef.current;
      const mk = (c?: HTMLCanvasElement) => {
        const el = c ?? document.createElement("canvas");
        el.width = W;
        el.height = H;
        return el;
      };
      const maskC = mk(prev?.maskC);
      const mctx = maskC.getContext("2d", { willReadFrequently: true });
      if (!mctx) return;

      // Fit: one line, or a balanced two-line wrap if that renders larger.
      const probe = 100;
      const font = (px: number) => `800 ${px}px ${fontFamily}`;
      const setSpacing = (px: number) => {
        if ("letterSpacing" in mctx) {
          (
            mctx as CanvasRenderingContext2D & { letterSpacing: string }
          ).letterSpacing = `${px}px`;
        }
      };
      mctx.setTransform(1, 0, 0, 1, 0, 0);
      mctx.clearRect(0, 0, W, H);
      mctx.font = font(probe);
      setSpacing(probe * 0.03);
      const target = fontSize ?? Math.min(h * 0.48, 200);
      const fit = (lines: string[]) => {
        const widest =
          Math.max(...lines.map((l) => mctx.measureText(l).width), 1) / probe;
        // Plate padding: 0.32em each side, 0.24em top/bottom.
        return Math.min(
          target,
          (w * 0.94) / (widest + 0.64),
          (h * 0.92) / (lines.length * 1.04 + 0.5),
        );
      };
      let lines = [text];
      let fs = fit(lines);
      const words = text.split(" ");
      if (words.length > 1) {
        let best: string[] | null = null;
        let bestDiff = Infinity;
        for (let i = 1; i < words.length; i++) {
          const a = words.slice(0, i).join(" ");
          const b = words.slice(i).join(" ");
          const diff = Math.abs(
            mctx.measureText(a).width - mctx.measureText(b).width,
          );
          if (diff < bestDiff) {
            bestDiff = diff;
            best = [a, b];
          }
        }
        if (best) {
          const fs2 = fit(best);
          if (fs2 > fs * 1.25) {
            lines = best;
            fs = fs2;
          }
        }
      }

      mctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      mctx.font = font(fs);
      setSpacing(fs * 0.03);
      mctx.textAlign = "center";
      mctx.textBaseline = "alphabetic";
      const m = mctx.measureText(lines.join(""));
      const ascent = m.actualBoundingBoxAscent || fs * 0.74;
      const descent = m.actualBoundingBoxDescent || fs * 0.04;
      const lh = fs * 1.04;
      const blockH = (lines.length - 1) * lh + ascent + descent;
      const top = (h - blockH) / 2;
      mctx.fillStyle = "#fff";
      let bx0 = w;
      let bx1 = 0;
      lines.forEach((line, i) => {
        // letterSpacing trails the last glyph — nudge back by half to stay centered.
        mctx.fillText(line, w / 2 + fs * 0.015, top + ascent + i * lh);
        const lw = mctx.measureText(line).width;
        bx0 = Math.min(bx0, w / 2 - lw / 2);
        bx1 = Math.max(bx1, w / 2 + lw / 2);
      });
      const box = { x0: bx0, y0: top, x1: bx1, y1: top + blockH };

      // CSS-px mask for bridging + drip spots.
      const readMask = () => {
        const data = mctx.getImageData(0, 0, W, H).data;
        const mask = new Uint8Array(w * h);
        for (let y = 0; y < h; y++) {
          const sy = Math.min(H - 1, Math.round((y + 0.5) * dpr - 0.5));
          for (let x = 0; x < w; x++) {
            const sx = Math.min(W - 1, Math.round((x + 0.5) * dpr - 0.5));
            mask[y * w + x] = data[(sy * W + sx) * 4 + 3] > 110 ? 1 : 0;
          }
        }
        return mask;
      };
      let mask = readMask();

      // Stencil bridges: any enclosed counter would fall out of a real plate,
      // so cut a tie above and below each one.
      {
        const N = w * h;
        const outside = new Uint8Array(N);
        const queue = new Int32Array(N);
        let qh = 0;
        let qt = 0;
        const push = (i: number) => {
          if (!mask[i] && !outside[i]) {
            outside[i] = 1;
            queue[qt++] = i;
          }
        };
        for (let x = 0; x < w; x++) {
          push(x);
          push((h - 1) * w + x);
        }
        for (let y = 0; y < h; y++) {
          push(y * w);
          push(y * w + w - 1);
        }
        while (qh < qt) {
          const i = queue[qh++];
          const x = i % w;
          if (x > 0) push(i - 1);
          if (x < w - 1) push(i + 1);
          if (i >= w) push(i - w);
          if (i < N - w) push(i + w);
        }
        const seen = new Uint8Array(N);
        const bw = Math.max(1.6, fs * 0.06);
        const minArea = Math.max(6, fs * fs * 0.0015);
        const strips: [number, number, number, number][] = [];
        for (let s = 0; s < N; s++) {
          if (mask[s] || outside[s] || seen[s]) continue;
          // Flood one counter.
          qh = 0;
          qt = 0;
          seen[s] = 1;
          queue[qt++] = s;
          let x0 = w;
          let x1 = 0;
          let y0 = h;
          let y1 = 0;
          let sumX = 0;
          while (qh < qt) {
            const i = queue[qh++];
            const x = i % w;
            const y = (i - x) / w;
            sumX += x;
            if (x < x0) x0 = x;
            if (x > x1) x1 = x;
            if (y < y0) y0 = y;
            if (y > y1) y1 = y;
            const nb = [
              x > 0 ? i - 1 : -1,
              x < w - 1 ? i + 1 : -1,
              i >= w ? i - w : -1,
              i < N - w ? i + w : -1,
            ];
            for (const j of nb) {
              if (j >= 0 && !mask[j] && !outside[j] && !seen[j]) {
                seen[j] = 1;
                queue[qt++] = j;
              }
            }
          }
          if (qt < minArea) continue;
          const cx = Math.round((sumX / qt + (x0 + x1) / 2) / 2);
          // Find the column's top-most counter pixel, then cut upward through ink.
          const colTop = (() => {
            for (let y = y0; y <= y1; y++)
              if (seen[y * w + cx] && !mask[y * w + cx]) return y;
            return y0;
          })();
          const colBot = (() => {
            for (let y = y1; y >= y0; y--)
              if (seen[y * w + cx] && !mask[y * w + cx]) return y;
            return y1;
          })();
          let y = colTop - 1;
          while (y > 0 && mask[y * w + cx]) y--;
          strips.push([cx - bw / 2, y, bw, colTop - y + 1]);
          y = colBot + 1;
          while (y < h - 1 && mask[y * w + cx]) y++;
          strips.push([cx - bw / 2, colBot, bw, y - colBot + 1]);
        }
        if (strips.length) {
          for (const [sx, sy, sw, sh] of strips) mctx.clearRect(sx, sy, sw, sh);
          mask = readMask();
        }
      }

      // Bottom-edge drip candidates.
      const edges: number[] = [];
      const stepX = Math.max(1, Math.round(fs * 0.02));
      for (
        let y = Math.max(1, Math.floor(box.y0));
        y < Math.min(h - 3, Math.ceil(box.y1) + 2);
        y++
      ) {
        for (let x = 1; x < w - 1; x += stepX) {
          const i = y * w + x;
          if (!(
            mask[i] &&
            mask[i - 1] &&
            mask[i + 1] &&
            !mask[i + w] &&
            !mask[i + 2 * w]
          ))
            continue;
          // Only the letters' true feet — no ink below in this column for half an em.
          let clear = true;
          for (let k = 2; k < fs * 0.9 && y + k < h; k++)
            if (mask[i + k * w]) clear = false;
          if (clear) edges.push(x, y);
        }
      }

      // Padded masks: hard (drips creep ~3px) and soft (overspray falloff).
      const stamp = (
        c: HTMLCanvasElement,
        radii: number[],
        alphas: number[],
      ) => {
        const ctx = c.getContext("2d")!;
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, W, H);
        radii.forEach((r, ri) => {
          ctx.globalAlpha = alphas[ri];
          for (let a = 0; a < 12; a++) {
            const ang = (a / 12) * Math.PI * 2;
            ctx.drawImage(
              maskC,
              Math.cos(ang) * r * dpr,
              Math.sin(ang) * r * dpr,
            );
          }
        });
        ctx.globalAlpha = 1;
        ctx.drawImage(maskC, 0, 0);
      };
      const padHardC = mk(prev?.padHardC);
      stamp(padHardC, [1.5, 3], [1, 1]);
      const padSoftC = mk(prev?.padSoftC);
      const sr = clamp(fs * 0.055, 3, 10);
      stamp(padSoftC, [sr, sr * 0.66, sr * 0.33], [0.06, 0.1, 0.22]);

      // The plate: graphite with lighter edges, cut letters and two hanging holes.
      const padX = fs * 0.32;
      const padY = fs * 0.24;
      const plateRect = {
        x: box.x0 - padX,
        y: box.y0 - padY,
        w: box.x1 - box.x0 + padX * 2,
        h: box.y1 - box.y0 + padY * 2,
      };
      const plateC = mk(prev?.plateC);
      {
        const ctx = cssCtx(plateC, dpr);
        ctx.clearRect(0, 0, w, h);
        const { x, y, w: pw, h: ph } = plateRect;
        const rr = Math.min(8, fs * 0.06);
        const path = new Path2D();
        path.roundRect(x, y, pw, ph, rr);
        ctx.fillStyle = plate;
        ctx.fill(path);
        ctx.save();
        ctx.clip(path);
        const g = ctx.createLinearGradient(0, y, 0, y + ph);
        g.addColorStop(0, "rgba(255,255,255,0.07)");
        g.addColorStop(0.5, "rgba(255,255,255,0)");
        g.addColorStop(1, "rgba(0,0,0,0.18)");
        ctx.fillStyle = g;
        ctx.fillRect(x, y, pw, ph);
        // Milled-sheet speckle.
        const rand = mulberry32(seed * 53 + 3);
        const specks = Math.round((pw * ph) / 90);
        for (let i = 0; i < specks; i++) {
          ctx.fillStyle =
            rand() < 0.5 ? "rgba(255,255,255,0.035)" : "rgba(0,0,0,0.08)";
          ctx.fillRect(x + rand() * pw, y + rand() * ph, 1, 1);
        }
        // Lighter worn edges.
        ctx.lineWidth = 2;
        ctx.strokeStyle = "rgba(255,255,255,0.09)";
        ctx.stroke(path);
        ctx.restore();
        ctx.lineWidth = 1;
        ctx.strokeStyle = "rgba(255,255,255,0.14)";
        const inner = new Path2D();
        inner.roundRect(x + 0.5, y + 0.5, pw - 1, ph - 1, rr);
        ctx.stroke(inner);
        // Cut the letters (and two hanging holes) out of the sheet.
        ctx.globalCompositeOperation = "destination-out";
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.drawImage(maskC, 0, 0);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        const hr = clamp(fs * 0.035, 2, 6);
        for (const hx of [x + padX * 0.42, x + pw - padX * 0.42]) {
          ctx.beginPath();
          ctx.arc(hx, y + padY * 0.48, hr, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.globalCompositeOperation = "source-over";
      }
      // Cut-face rim: the plate's far edge of each cutout catches the light.
      const tmpC = mk(prev?.tmpC);
      {
        const t = tmpC.getContext("2d")!;
        t.setTransform(1, 0, 0, 1, 0, 0);
        t.globalCompositeOperation = "copy";
        t.drawImage(maskC, 1.2 * dpr, 1.2 * dpr);
        t.globalCompositeOperation = "destination-out";
        t.drawImage(maskC, 0, 0);
        t.globalCompositeOperation = "source-in";
        t.fillStyle = "rgba(255,255,255,0.2)";
        t.fillRect(0, 0, W, H);
        t.globalCompositeOperation = "source-over";
        const p = plateC.getContext("2d")!;
        p.setTransform(1, 0, 0, 1, 0, 0);
        p.globalCompositeOperation = "source-atop";
        p.drawImage(tmpC, 0, 0);
        p.globalCompositeOperation = "source-over";
      }
      // Plate thickness: hairline shadow inside each cutout's top-left edge.
      const cutShadowC = mk(prev?.cutShadowC);
      {
        const c = cutShadowC.getContext("2d")!;
        c.setTransform(1, 0, 0, 1, 0, 0);
        c.globalCompositeOperation = "copy";
        c.drawImage(maskC, 0, 0);
        c.globalCompositeOperation = "destination-out";
        c.drawImage(maskC, 2 * dpr, 2 * dpr);
        c.globalCompositeOperation = "source-in";
        c.fillStyle = "rgba(0,0,0,0.6)";
        c.fillRect(0, 0, W, H);
        c.globalCompositeOperation = "source-over";
      }

      // Soft spray sprite in the paint color.
      const blob = prev?.blob ?? document.createElement("canvas");
      blob.width = blob.height = 64;
      {
        const b = blob.getContext("2d")!;
        b.clearRect(0, 0, 64, 64);
        // Solid paint, alpha-shaped below — a color gradient to transparent
        // black would leave a dark fringe on every splat.
        b.fillStyle = paint;
        b.globalAlpha = 1;
        b.fillRect(0, 0, 64, 64);
        // Feather the core so the coat builds softly.
        b.globalCompositeOperation = "destination-in";
        const f = b.createRadialGradient(32, 32, 0, 32, 32, 32);
        f.addColorStop(0, "rgba(0,0,0,1)");
        f.addColorStop(0.5, "rgba(0,0,0,0.55)");
        f.addColorStop(1, "rgba(0,0,0,0)");
        b.fillStyle = f;
        b.fillRect(0, 0, 64, 64);
        b.globalCompositeOperation = "source-over";
      }

      const passTotal = passCount * lines.length;
      const R = Math.max(((box.y1 - box.y0) / passTotal) * 1.3, fs * 0.28);
      const passDur = clamp(0.5 + (box.x1 - box.x0) / 1500, 0.6, 1.05);
      const sprayEnd = START + passTotal * (passDur + GAP) - GAP;

      const L: Layout = {
        w,
        h,
        dpr,
        fs,
        lines: lines.length,
        mask,
        box,
        plateRect,
        edges,
        maskC,
        padSoftC,
        padHardC,
        plateC,
        cutShadowC,
        wordC: mk(prev?.wordC),
        platePaintC: mk(prev?.platePaintC),
        freshC: mk(prev?.freshC),
        coatC: mk(prev?.coatC),
        dripFreshC: mk(prev?.dripFreshC),
        tmpC,
        blob,
        R,
        passDur,
        passCount: passTotal,
        sprayEnd,
        liftAt: sprayEnd + SETTLE,
      };
      layoutRef.current = L;

      if (reducedRef.current || introDoneRef.current) {
        introDoneRef.current = true;
        paintInstant(L);
      } else {
        // (Re)start the intro against the new geometry.
        clearPaint(L);
        randRef.current = mulberry32(seed);
        clockRef.current = 0;
        planDrips(L);
      }
      dirtyRef.current = true;
      render();
    };

    const ro = new ResizeObserver(() => build(false));
    ro.observe(container);
    build(true);
    // Re-measure once web fonts land so the mask matches the final glyphs.
    document.fonts?.ready.then(() => build(true)).catch(() => {});

    return () => {
      cancelled = true;
      ro.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    text,
    fontSize,
    fontFamily,
    paint,
    plate,
    passCount,
    seed,
    paintInstant,
    planDrips,
    render,
  ]);

  // Reduced motion flipping on mid-intro: jump to the finished word.
  React.useEffect(() => {
    const L = layoutRef.current;
    if (!reducedMotion || !L || introDoneRef.current) return;
    introDoneRef.current = true;
    paintInstant(L);
    render();
  }, [reducedMotion, paintInstant, render]);

  const replay = React.useCallback(() => {
    const L = layoutRef.current;
    if (!L) return;
    if (reducedRef.current) {
      paintInstant(L);
      render();
      return;
    }
    clearPaint(L);
    randRef.current = mulberry32(seed);
    clockRef.current = 0;
    introDoneRef.current = false;
    planDrips(L);
    dirtyRef.current = true;
    render();
  }, [paintInstant, planDrips, render, seed]);

  React.useImperativeHandle(ref, () => ({ replay }), [replay]);

  // ---------------------------------------------------------------- input
  const local = (event: React.PointerEvent<HTMLDivElement>) => {
    const rect = event.currentTarget.getBoundingClientRect();
    return [event.clientX - rect.left, event.clientY - rect.top] as const;
  };

  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(event);
    if (event.defaultPrevented || event.button > 0) return;
    const [x, y] = local(event);
    event.currentTarget.setPointerCapture?.(event.pointerId);
    dragRef.current = {
      active: true,
      id: event.pointerId,
      x,
      y,
      lx: x,
      ly: y,
      dwell: 0,
    };
    if (reducedRef.current) {
      const L = layoutRef.current;
      if (!L) return;
      sprayDrag(L, 1 / 60);
      flush(L, OVERSPRAY * 2);
      render();
    }
  };

  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    const d = dragRef.current;
    if (!d.active || d.id !== event.pointerId) return;
    const [x, y] = local(event);
    d.x = x;
    d.y = y;
    if (reducedRef.current) {
      const L = layoutRef.current;
      if (!L) return;
      sprayDrag(L, 1 / 60);
      flush(L, OVERSPRAY * 2);
      render();
    }
  };

  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    if (dragRef.current.id === event.pointerId) dragRef.current.active = false;
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      replay();
    }
  };

  return (
    <div
      ref={containerRef}
      data-crucible="stencil"
      role="group"
      tabIndex={0}
      aria-roledescription="spray-painted headline"
      aria-label={`${text} — drag to add paint, press Enter to spray it again`}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(e) => {
        onPointerUp?.(e);
        endDrag(e);
      }}
      onPointerCancel={(e) => {
        onPointerCancel?.(e);
        endDrag(e);
      }}
      onKeyDown={handleKeyDown}
      className={cn(
        "relative block h-full min-h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md outline-none select-none focus-visible:ring-2 focus-visible:ring-white/40",
        className,
      )}
      {...props}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 h-full w-full"
      />
      {/* Real text for screen readers + SEO — the canvas is purely presentational. */}
      <Tag className="sr-only">{text}</Tag>
    </div>
  );
}
```
