Hero Letterpress Lockup

Heroloud

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.

Install
npx shadcn add https://design.helpmarq.com/r/hero-letterpress-lockup.json
Source
registry/loud/hero-letterpress-lockup/component.tsx
"use client";

import { useEffect, useMemo, useRef } from "react";

// ---------------------------------------------------------------------------
// QuoinLock — a letterpress hero whose identity is a terminal lock-up event.
// The headline is split into per-glyph spans (aria-hidden) that arrive as
// individual metal sorts sliding along invisible composing rails: each glyph
// starts translateX'd +/-40-120px on its own critically-ish damped spring
// (randomized stiffness + damping per glyph), overshoots its rest slot by up
// to 4px, and settles. The instant every glyph is settled, the quoin visibly
// tightens in one mechanical "clunk": letter-spacing tweens -0.02em tighter
// over 90ms ease-in, a hairline 1px --border chase rect scales 101% -> 100%
// around the assembled block, the headline's text-shadow flashes
// 0 1px 0 --border for ~80ms simulating an impression, and the subhead
// prints beneath starting at --foreground before easing to --muted (the
// impression "drying"). After that the hero is completely static — no
// ambient loop, no hover state; assembly is only the setup for the single
// tightening moment where spacing, chase and press-shadow commit at once.
// The full headline exists once, always, as a visually hidden real h1 so
// screen readers get one ordered string immediately; glyph spans are
// aria-hidden and never focusable, and no content is gated behind the
// animation (the subhead is real text, only its opacity is animated).
// Direct-DOM writes only — no React state on the animated path.
// ---------------------------------------------------------------------------

function mulberry32(seed: number) {
  let a = seed | 0;
  return () => {
    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;
  };
}

type GlyphRail = {
  offset: number; // starting px along the rail, signed
  delay: number; // ms before this glyph's spring engages
  k: number; // stiffness, s^-2
  zeta: number; // damping ratio
  animate: boolean; // false for whitespace glyphs — they never move
};

const MAX_OVERSHOOT = 4; // px past the rail's zero point, clamped
const SETTLE_DEADLINE_MS = 850; // hard cap: the line must be locked by here.
// Residual amplitude at the deadline is <2px with the stiffness/damping ranges
// below, so the forced snap is invisible — without it, a soft-rolled spring
// could still be moving past 1.5s and the resting state would race any
// observer (the verify gate screenshots "default" at ~1s after load).
const BASE_TRACKING = -0.01; // em — resting tracking while composing
const LOCKED_TRACKING = -0.03; // em — resting + the -0.02em clunk delta
const CLUNK_MS = 90;
const CHASE_MS = 160;
const IMPRESSION_MS = 80;
const STAMP_HOLD_MS = 90; // impression dwell before it eases back to --muted

export function QuoinLock({
  eyebrow = "COMPOSING ROOM",
  headline = "SET IN TYPE, LOCKED FOR GOOD",
  subhead = "Every glyph rides its own rail into place. Once the line is full, the quoin tightens and it doesn't move again.",
  className = "",
}: {
  /** mono eyebrow label above the headline */
  eyebrow?: string;
  /** headline text — split into per-glyph rails; also the sr-only h1's full string */
  headline?: string;
  /** subhead copy, printed beneath once the line locks */
  subhead?: string;
  className?: string;
}) {
  const rootRef = useRef<HTMLElement>(null);
  const lineRef = useRef<HTMLSpanElement>(null);
  const chaseRef = useRef<HTMLDivElement>(null);
  const subheadRef = useRef<HTMLParagraphElement>(null);
  const glyphRefs = useRef<(HTMLSpanElement | null)[]>([]);

  const glyphs = useMemo(() => Array.from(headline), [headline]);

  // deterministic per-glyph rail params, seeded off the headline text so
  // re-renders (and repeat mounts of the same headline) agree
  const rails = useMemo<GlyphRail[]>(() => {
    const rand = mulberry32(headline.length * 977 + 0x51a1);
    return Array.from(headline).map((ch) => {
      const animate = ch.trim().length > 0;
      const sign = rand() < 0.5 ? -1 : 1;
      const mag = 40 + rand() * 80; // 40-120px
      return {
        offset: animate ? sign * mag : 0,
        delay: rand() * 140, // ms stagger — sorts arrive in a loose sequence
        k: 120 + rand() * 120, // 120-240 s^-2
        zeta: 0.6 + rand() * 0.25, // 0.6-0.85
        animate,
      };
    });
  }, [headline]);

  useEffect(() => {
    const root = rootRef.current;
    const line = lineRef.current;
    const chase = chaseRef.current;
    const subheadEl = subheadRef.current;
    if (!root || !line || !chase || !subheadEl) return;

    const spans = glyphRefs.current.slice(0, glyphs.length);
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let disposed = false;
    let stampTimer = 0;
    let fadeTimer = 0;

    // chase sizing: per-glyph transforms never touch layout (translateX is
    // paint-only), so the line's natural bounding box is already the final
    // assembled size from the very first frame — no reflow to chase.
    const sizeChase = () => {
      const lineRect = line.getBoundingClientRect();
      // measure against the chase's actual containing block (the relative
      // inline-block wrapper), NOT the section root — absolute left/top are
      // resolved against the offset parent, and using the root's rect here
      // double-applied the line's own offset and threw the frame off-canvas
      const parentRect = (chase.offsetParent as HTMLElement | null)?.getBoundingClientRect();
      if (!parentRect || lineRect.width < 1) return;
      const padX = 18;
      const padY = 12;
      chase.style.left = `${lineRect.left - parentRect.left - padX}px`;
      chase.style.top = `${lineRect.top - parentRect.top - padY}px`;
      chase.style.width = `${lineRect.width + padX * 2}px`;
      chase.style.height = `${lineRect.height + padY * 2}px`;
    };

    const finalizeGlyphs = () => {
      for (const span of spans) if (span) span.style.transform = "translateX(0px)";
    };

    // the clunk: letter-spacing tightens, chase snaps to fit, an impression
    // flash hits the headline, and the subhead prints — all in one beat
    const lockUp = () => {
      finalizeGlyphs();

      line.style.transitionProperty = "letter-spacing";
      line.style.transitionDuration = `${CLUNK_MS}ms`;
      line.style.transitionTimingFunction = "ease-in";
      line.style.letterSpacing = `${LOCKED_TRACKING}em`;

      chase.style.transitionProperty = "transform, opacity";
      chase.style.transitionDuration = `${CHASE_MS}ms`;
      chase.style.transitionTimingFunction = "cubic-bezier(0.16, 1, 0.3, 1)";
      chase.style.opacity = "1";
      chase.style.transform = "scale(1)";

      // impression: instant on (no transition yet), then eased off below
      line.style.textShadow = "0 1px 0 var(--border)";
      subheadEl.style.opacity = "1";
      subheadEl.style.transitionProperty = "color";
      subheadEl.style.transitionDuration = "0ms";
      subheadEl.style.color = "var(--foreground)";

      stampTimer = window.setTimeout(() => {
        if (disposed) return;
        line.style.transitionProperty = "letter-spacing, text-shadow";
        line.style.transitionDuration = `${CLUNK_MS}ms, ${IMPRESSION_MS}ms`;
        line.style.textShadow = "0 0 0 transparent";
        subheadEl.style.transitionDuration = "320ms";
        subheadEl.style.transitionTimingFunction = "ease-out";
        subheadEl.style.color = "var(--muted)";
      }, STAMP_HOLD_MS);
    };

    if (reduced) {
      // instant final locked state — no springs, no stamp flash — plus the
      // single required 200ms fade for the whole hero
      finalizeGlyphs();
      sizeChase();
      chase.style.opacity = "1";
      chase.style.transform = "scale(1)";
      line.style.letterSpacing = `${LOCKED_TRACKING}em`;
      line.style.textShadow = "0 0 0 transparent";
      subheadEl.style.opacity = "1";
      subheadEl.style.color = "var(--muted)";

      root.style.opacity = "0";
      root.style.transitionProperty = "opacity";
      root.style.transitionDuration = "200ms";
      fadeTimer = window.setTimeout(() => {
        if (!disposed) root.style.opacity = "1";
      }, 16);

      const ro = new ResizeObserver(sizeChase);
      ro.observe(line);
      document.fonts.ready.then(() => {
        if (!disposed) sizeChase();
      });
      return () => {
        disposed = true;
        window.clearTimeout(fadeTimer);
        ro.disconnect();
      };
    }

    // -- full spring path -----------------------------------------------
    const n = spans.length;
    const p = new Float64Array(n);
    const v = new Float64Array(n);
    for (let i = 0; i < n; i++) p[i] = rails[i]?.offset ?? 0;
    for (let i = 0; i < n; i++) {
      const span = spans[i];
      if (span) {
        span.style.willChange = "transform";
        span.style.transform = `translateX(${p[i].toFixed(2)}px)`;
      }
    }

    sizeChase();
    document.fonts.ready.then(() => {
      if (!disposed) sizeChase();
    });

    let raf = 0;
    let last = 0;
    let locked = false;
    const startTime = performance.now();

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.032, (now - last) / 1000);
      last = now;
      const elapsed = now - startTime;
      const overdue = elapsed >= SETTLE_DEADLINE_MS;
      let allSettled = true;

      for (let i = 0; i < n; i++) {
        const rail = rails[i];
        if (!rail || !rail.animate) continue;
        if (overdue) {
          // deadline: snap the (already sub-2px) residue home so lockUp fires
          p[i] = 0;
          v[i] = 0;
          const s = spans[i];
          if (s) s.style.transform = "translateX(0px)";
          continue;
        }
        if (elapsed < rail.delay) {
          allSettled = false;
          continue;
        }
        const c = 2 * rail.zeta * Math.sqrt(rail.k);
        const accel = -rail.k * p[i] - c * v[i];
        v[i] += accel * dt;
        p[i] += v[i] * dt;

        // clamp overshoot past the rail's zero point to MAX_OVERSHOOT
        const startSign = rail.offset < 0 ? -1 : 1;
        if (Math.sign(p[i]) !== 0 && Math.sign(p[i]) !== startSign && Math.abs(p[i]) > MAX_OVERSHOOT) {
          p[i] = -startSign * MAX_OVERSHOOT;
        }

        const span = spans[i];
        if (span) span.style.transform = `translateX(${p[i].toFixed(2)}px)`;
        // 0.3px / 2px-per-s is already sub-perceptual — a tighter epsilon just
        // stretches the tail of the decay and delays the lock beat
        if (Math.abs(p[i]) > 0.3 || Math.abs(v[i]) > 2) allSettled = false;
      }

      if (allSettled && !locked) {
        locked = true;
        lockUp();
        raf = 0;
        return; // settled — CSS transitions carry the clunk from here
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    const ro = new ResizeObserver(sizeChase);
    ro.observe(line);

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      window.clearTimeout(stampTimer);
      ro.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [glyphs, rails]);

  return (
    <section
      ref={rootRef}
      className={`relative isolate overflow-hidden bg-background ${className}`}
    >
      {/* real, full headline — always present, visually hidden, read once and in order */}
      <h1 className="sr-only">{headline}</h1>
      <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">
        {eyebrow ? (
          <p className="mb-6 font-mono text-[11px] tracking-widest text-muted">{eyebrow}</p>
        ) : null}
        <div className="relative inline-block">
          <span
            ref={lineRef}
            aria-hidden
            className="relative z-10 inline-block whitespace-pre font-semibold text-foreground"
            style={{
              fontSize: "clamp(2.1rem, 6vw, 4rem)",
              lineHeight: 1.1,
              letterSpacing: `${BASE_TRACKING}em`,
            }}
          >
            {glyphs.map((ch, i) => (
              <span
                key={i}
                ref={(el) => {
                  glyphRefs.current[i] = el;
                }}
                className="inline-block"
              >
                {ch === " " ? " " : ch}
              </span>
            ))}
          </span>
          {/* the chase — a hairline frame that snaps to fit around the locked block */}
          <div
            ref={chaseRef}
            aria-hidden
            className="pointer-events-none absolute rounded-none border border-border"
            style={{ opacity: 0, transform: "scale(1.01)" }}
          />
        </div>
        <p
          ref={subheadRef}
          className="mt-6 max-w-xl text-base leading-relaxed text-muted"
          style={{ opacity: 0 }}
        >
          {subhead}
        </p>
      </div>
    </section>
  );
}
Use when

a hero whose headline arrives once as individually sprung glyph sorts and then commits in a single mechanical tightening (letter-spacing compression + a hairline chase frame snapping to fit + an impression flash) before going fully static — unlike hero-dipole-field (continuous cursor-attraction field) or text-ligature-melt (hover-driven glyph fusion) or text-variable-weight (weight as a live continuous state), this one is about type becoming FIXED: the arrival is only the setup for one terminal event, not an ongoing interaction.

Build spec

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 --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/--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 --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.

Tags
herotexttypographyletterpressspringentranceprint