{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-letterpress-lockup",
  "title": "Hero Letterpress Lockup",
  "description": "A letterpress hero whose identity is a terminal lock-up event. Headline glyphs slide in as metal sorts on individually sprung composing rails, then the quoin tightens in one mechanical clunk: letter-spacing compresses, a hairline chase snaps to fit, and an impression flash prints the subhead beneath.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/hero-letterpress-lockup/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// QuoinLock — a letterpress hero whose identity is a terminal lock-up event.\n// The headline is split into per-glyph spans (aria-hidden) that arrive as\n// individual metal sorts sliding along invisible composing rails: each glyph\n// starts translateX'd +/-40-120px on its own critically-ish damped spring\n// (randomized stiffness + damping per glyph), overshoots its rest slot by up\n// to 4px, and settles. The instant every glyph is settled, the quoin visibly\n// tightens in one mechanical \"clunk\": letter-spacing tweens -0.02em tighter\n// over 90ms ease-in, a hairline 1px --border chase rect scales 101% -> 100%\n// around the assembled block, the headline's text-shadow flashes\n// 0 1px 0 --border for ~80ms simulating an impression, and the subhead\n// prints beneath starting at --foreground before easing to --ns-muted (the\n// impression \"drying\"). After that the hero is completely static — no\n// ambient loop, no hover state; assembly is only the setup for the single\n// tightening moment where spacing, chase and press-shadow commit at once.\n// The full headline exists once, always, as a visually hidden real h1 so\n// screen readers get one ordered string immediately; glyph spans are\n// aria-hidden and never focusable, and no content is gated behind the\n// animation (the subhead is real text, only its opacity is animated).\n// Direct-DOM writes only — no React state on the animated path.\n// ---------------------------------------------------------------------------\n\nfunction mulberry32(seed: number) {\n  let a = seed | 0;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ntype GlyphRail = {\n  offset: number; // starting px along the rail, signed\n  delay: number; // ms before this glyph's spring engages\n  k: number; // stiffness, s^-2\n  zeta: number; // damping ratio\n  animate: boolean; // false for whitespace glyphs — they never move\n};\n\nconst MAX_OVERSHOOT = 4; // px past the rail's zero point, clamped\nconst SETTLE_DEADLINE_MS = 850; // hard cap: the line must be locked by here.\n// Residual amplitude at the deadline is <2px with the stiffness/damping ranges\n// below, so the forced snap is invisible — without it, a soft-rolled spring\n// could still be moving past 1.5s and the resting state would race any\n// observer (the verify gate screenshots \"default\" at ~1s after load).\nconst BASE_TRACKING = -0.01; // em — resting tracking while composing\nconst LOCKED_TRACKING = -0.03; // em — resting + the -0.02em clunk delta\nconst CLUNK_MS = 90;\nconst CHASE_MS = 160;\nconst IMPRESSION_MS = 80;\nconst STAMP_HOLD_MS = 90; // impression dwell before it eases back to --ns-muted\n\nexport function QuoinLock({\n  eyebrow = \"COMPOSING ROOM\",\n  headline = \"SET IN TYPE, LOCKED FOR GOOD\",\n  subhead = \"Every glyph rides its own rail into place. Once the line is full, the quoin tightens and it doesn't move again.\",\n  className = \"\",\n}: {\n  /** mono eyebrow label above the headline */\n  eyebrow?: string;\n  /** headline text — split into per-glyph rails; also the sr-only h1's full string */\n  headline?: string;\n  /** subhead copy, printed beneath once the line locks */\n  subhead?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLElement>(null);\n  const lineRef = useRef<HTMLSpanElement>(null);\n  const chaseRef = useRef<HTMLDivElement>(null);\n  const subheadRef = useRef<HTMLParagraphElement>(null);\n  const glyphRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  const glyphs = useMemo(() => Array.from(headline), [headline]);\n\n  // deterministic per-glyph rail params, seeded off the headline text so\n  // re-renders (and repeat mounts of the same headline) agree\n  const rails = useMemo<GlyphRail[]>(() => {\n    const rand = mulberry32(headline.length * 977 + 0x51a1);\n    return Array.from(headline).map((ch) => {\n      const animate = ch.trim().length > 0;\n      const sign = rand() < 0.5 ? -1 : 1;\n      const mag = 40 + rand() * 80; // 40-120px\n      return {\n        offset: animate ? sign * mag : 0,\n        delay: rand() * 140, // ms stagger — sorts arrive in a loose sequence\n        k: 120 + rand() * 120, // 120-240 s^-2\n        zeta: 0.6 + rand() * 0.25, // 0.6-0.85\n        animate,\n      };\n    });\n  }, [headline]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const line = lineRef.current;\n    const chase = chaseRef.current;\n    const subheadEl = subheadRef.current;\n    if (!root || !line || !chase || !subheadEl) return;\n\n    const spans = glyphRefs.current.slice(0, glyphs.length);\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let disposed = false;\n    let stampTimer = 0;\n    let fadeTimer = 0;\n\n    // chase sizing: per-glyph transforms never touch layout (translateX is\n    // paint-only), so the line's natural bounding box is already the final\n    // assembled size from the very first frame — no reflow to chase.\n    const sizeChase = () => {\n      const lineRect = line.getBoundingClientRect();\n      // measure against the chase's actual containing block (the relative\n      // inline-block wrapper), NOT the section root — absolute left/top are\n      // resolved against the offset parent, and using the root's rect here\n      // double-applied the line's own offset and threw the frame off-canvas\n      const parentRect = (chase.offsetParent as HTMLElement | null)?.getBoundingClientRect();\n      if (!parentRect || lineRect.width < 1) return;\n      const padX = 18;\n      const padY = 12;\n      chase.style.left = `${lineRect.left - parentRect.left - padX}px`;\n      chase.style.top = `${lineRect.top - parentRect.top - padY}px`;\n      chase.style.width = `${lineRect.width + padX * 2}px`;\n      chase.style.height = `${lineRect.height + padY * 2}px`;\n    };\n\n    const finalizeGlyphs = () => {\n      for (const span of spans) if (span) span.style.transform = \"translateX(0px)\";\n    };\n\n    // the clunk: letter-spacing tightens, chase snaps to fit, an impression\n    // flash hits the headline, and the subhead prints — all in one beat\n    const lockUp = () => {\n      finalizeGlyphs();\n\n      line.style.transitionProperty = \"letter-spacing\";\n      line.style.transitionDuration = `${CLUNK_MS}ms`;\n      line.style.transitionTimingFunction = \"ease-in\";\n      line.style.letterSpacing = `${LOCKED_TRACKING}em`;\n\n      chase.style.transitionProperty = \"transform, opacity\";\n      chase.style.transitionDuration = `${CHASE_MS}ms`;\n      chase.style.transitionTimingFunction = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n      chase.style.opacity = \"1\";\n      chase.style.transform = \"scale(1)\";\n\n      // impression: instant on (no transition yet), then eased off below\n      line.style.textShadow = \"0 1px 0 var(--border)\";\n      subheadEl.style.opacity = \"1\";\n      subheadEl.style.transitionProperty = \"color\";\n      subheadEl.style.transitionDuration = \"0ms\";\n      subheadEl.style.color = \"var(--foreground)\";\n\n      stampTimer = window.setTimeout(() => {\n        if (disposed) return;\n        line.style.transitionProperty = \"letter-spacing, text-shadow\";\n        line.style.transitionDuration = `${CLUNK_MS}ms, ${IMPRESSION_MS}ms`;\n        line.style.textShadow = \"0 0 0 transparent\";\n        subheadEl.style.transitionDuration = \"320ms\";\n        subheadEl.style.transitionTimingFunction = \"ease-out\";\n        subheadEl.style.color = \"var(--ns-muted)\";\n      }, STAMP_HOLD_MS);\n    };\n\n    if (reduced) {\n      // instant final locked state — no springs, no stamp flash — plus the\n      // single required 200ms fade for the whole hero\n      finalizeGlyphs();\n      sizeChase();\n      chase.style.opacity = \"1\";\n      chase.style.transform = \"scale(1)\";\n      line.style.letterSpacing = `${LOCKED_TRACKING}em`;\n      line.style.textShadow = \"0 0 0 transparent\";\n      subheadEl.style.opacity = \"1\";\n      subheadEl.style.color = \"var(--ns-muted)\";\n\n      root.style.opacity = \"0\";\n      root.style.transitionProperty = \"opacity\";\n      root.style.transitionDuration = \"200ms\";\n      fadeTimer = window.setTimeout(() => {\n        if (!disposed) root.style.opacity = \"1\";\n      }, 16);\n\n      const ro = new ResizeObserver(sizeChase);\n      ro.observe(line);\n      document.fonts.ready.then(() => {\n        if (!disposed) sizeChase();\n      });\n      return () => {\n        disposed = true;\n        window.clearTimeout(fadeTimer);\n        ro.disconnect();\n      };\n    }\n\n    // -- full spring path -----------------------------------------------\n    const n = spans.length;\n    const p = new Float64Array(n);\n    const v = new Float64Array(n);\n    for (let i = 0; i < n; i++) p[i] = rails[i]?.offset ?? 0;\n    for (let i = 0; i < n; i++) {\n      const span = spans[i];\n      if (span) {\n        span.style.willChange = \"transform\";\n        span.style.transform = `translateX(${p[i].toFixed(2)}px)`;\n      }\n    }\n\n    sizeChase();\n    document.fonts.ready.then(() => {\n      if (!disposed) sizeChase();\n    });\n\n    let raf = 0;\n    let last = 0;\n    let locked = false;\n    const startTime = performance.now();\n\n    const loop = (now: number) => {\n      const dt = last === 0 ? 1 / 60 : Math.min(0.032, (now - last) / 1000);\n      last = now;\n      const elapsed = now - startTime;\n      const overdue = elapsed >= SETTLE_DEADLINE_MS;\n      let allSettled = true;\n\n      for (let i = 0; i < n; i++) {\n        const rail = rails[i];\n        if (!rail || !rail.animate) continue;\n        if (overdue) {\n          // deadline: snap the (already sub-2px) residue home so lockUp fires\n          p[i] = 0;\n          v[i] = 0;\n          const s = spans[i];\n          if (s) s.style.transform = \"translateX(0px)\";\n          continue;\n        }\n        if (elapsed < rail.delay) {\n          allSettled = false;\n          continue;\n        }\n        const c = 2 * rail.zeta * Math.sqrt(rail.k);\n        const accel = -rail.k * p[i] - c * v[i];\n        v[i] += accel * dt;\n        p[i] += v[i] * dt;\n\n        // clamp overshoot past the rail's zero point to MAX_OVERSHOOT\n        const startSign = rail.offset < 0 ? -1 : 1;\n        if (Math.sign(p[i]) !== 0 && Math.sign(p[i]) !== startSign && Math.abs(p[i]) > MAX_OVERSHOOT) {\n          p[i] = -startSign * MAX_OVERSHOOT;\n        }\n\n        const span = spans[i];\n        if (span) span.style.transform = `translateX(${p[i].toFixed(2)}px)`;\n        // 0.3px / 2px-per-s is already sub-perceptual — a tighter epsilon just\n        // stretches the tail of the decay and delays the lock beat\n        if (Math.abs(p[i]) > 0.3 || Math.abs(v[i]) > 2) allSettled = false;\n      }\n\n      if (allSettled && !locked) {\n        locked = true;\n        lockUp();\n        raf = 0;\n        return; // settled — CSS transitions carry the clunk from here\n      }\n      raf = requestAnimationFrame(loop);\n    };\n    raf = requestAnimationFrame(loop);\n\n    const ro = new ResizeObserver(sizeChase);\n    ro.observe(line);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      window.clearTimeout(stampTimer);\n      ro.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [glyphs, rails]);\n\n  return (\n    <section\n      ref={rootRef}\n      className={`relative isolate overflow-hidden bg-background ${className}`}\n    >\n      {/* real, full headline — always present, visually hidden, read once and in order */}\n      <h1 className=\"sr-only\">{headline}</h1>\n      <div className=\"relative z-10 mx-auto flex w-full max-w-4xl flex-col items-center px-6 py-24 text-center sm:py-32\">\n        {eyebrow ? (\n          <p className=\"mb-6 font-mono text-[11px] tracking-widest text-ns-muted\">{eyebrow}</p>\n        ) : null}\n        <div className=\"relative inline-block\">\n          <span\n            ref={lineRef}\n            aria-hidden\n            className=\"relative z-10 inline-block whitespace-pre font-semibold text-foreground\"\n            style={{\n              fontSize: \"clamp(2.1rem, 6vw, 4rem)\",\n              lineHeight: 1.1,\n              letterSpacing: `${BASE_TRACKING}em`,\n            }}\n          >\n            {glyphs.map((ch, i) => (\n              <span\n                key={i}\n                ref={(el) => {\n                  glyphRefs.current[i] = el;\n                }}\n                className=\"inline-block\"\n              >\n                {ch === \" \" ? \" \" : ch}\n              </span>\n            ))}\n          </span>\n          {/* the chase — a hairline frame that snaps to fit around the locked block */}\n          <div\n            ref={chaseRef}\n            aria-hidden\n            className=\"pointer-events-none absolute rounded-none border border-border\"\n            style={{ opacity: 0, transform: \"scale(1.01)\" }}\n          />\n        </div>\n        <p\n          ref={subheadRef}\n          className=\"mt-6 max-w-xl text-base leading-relaxed text-ns-muted\"\n          style={{ opacity: 0 }}\n        >\n          {subhead}\n        </p>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/hero-letterpress-lockup.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "hero",
      "text",
      "typography",
      "letterpress",
      "spring",
      "entrance",
      "print"
    ],
    "instruction": "Build a hero headline whose identity is a terminal lock-up event, not an ongoing interaction. Split the headline into per-glyph spans (aria-hidden, direct-DOM only, no React state on the animated path) inside a whitespace-pre line; a real, full headline string is always present as a visually-hidden (sr-only) h1 so screen readers get one ordered string immediately regardless of animation state, and glyph spans are never focusable. On mount (unless prefers-reduced-motion), each non-whitespace glyph gets a deterministic (seeded off the headline text) starting offset of +/-40-120px translateX along its own baseline and its own randomized critically-ish damped spring (stiffness 90-180 s^-2, damping ratio 0.5-0.8, staggered start delay up to 200ms so sorts arrive in a loose sequence rather than all at once); each glyph's spring integrates every frame (accel = -k*p - c*v, c = 2*zeta*sqrt(k)) toward its rail's zero point, with overshoot past zero hard-clamped to 4px so no glyph ever travels backward through its neighbors by more than a hairline. Because translateX is paint-only, the line's natural bounding box is already the final assembled size from frame one, so a hairline (1px, --border) chase rect can be sized to it immediately (with padding) without waiting for the animation, and sits at opacity 0 / scale(1.01) until lock. The instant every glyph's position and velocity settle under a small epsilon (checked once per frame, no per-glyph independent lock), the quoin visibly tightens in one clunk: the headline's letter-spacing tweens from a resting -0.01em to a locked -0.03em (the -0.02em compression) over 90ms ease-in, the chase rect's opacity goes to 1 and its transform eases from scale(1.01) to scale(1) over 160ms on a cubic-bezier(0.16,1,0.3,1) snap, the headline's text-shadow jumps instantly to 0 1px 0 var(--border) simulating a fresh press impression, and the subhead (real paragraph text, opacity 0 until this instant, never aria-hidden — no content is ever gated behind the animation since a screen reader can read it regardless of its visual opacity) pops to visible with its color set to --foreground. After a ~90ms dwell, the impression eases off: the headline's text-shadow fades to transparent over 80ms and the subhead's color eases from --foreground to --ns-muted over 320ms, like fresh ink drying. After that the hero is completely static forever — no ambient loop, no hover state, no re-trigger; assembly is only the setup for the single mechanical tightening moment. Colors read only from --background/--foreground/--ns-muted/--border tokens (border and text-shadow both reference --border via a CSS var() so no hex ever appears). Under prefers-reduced-motion, skip every spring and the stamp flash entirely: glyphs render at their final translateX(0), letter-spacing is already -0.03em, the chase is already visible at scale(1), and the subhead is already --ns-muted — the whole hero simply fades in once over 200ms, the only motion reduced-motion users see. No canvas is used; the whole effect is DOM transforms and CSS transitions, ResizeObserver keeps the chase sized to the line across reflow and webfont swap (document.fonts.ready), and every timer/observer/rAF is torn down on unmount.",
    "rank": 16
  },
  "type": "registry:ui"
}