{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"anamorphic-heading","type":"registry:component","title":"Anamorphic Heading","description":"A heading that only reads from the right angle: its letters stand at different depths, scaled so they line up from straight ahead, and the view swings round to square as it scrolls in.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/anamorphic-heading.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, RefObject } from \"react\";\n\n/**\n * AnamorphicHeading — a heading that only reads from the right angle.\n *\n * Its letters stand at different depths, each scaled and shifted so that from\n * straight ahead they line up exactly; from anywhere else they scatter in\n * parallax. As the heading rises into view the view swings round from 36\n * degrees to square and the words resolve; the pointer leans it and they\n * part again. Real text; reduced motion shows it square. CSS 3D.\n *\n * Part of the Spatial 3D kit: a dark studio (#0a0a0d, panels #101114 and\n * #16171b, ink #f2f1ee) with anodised copper #ff7a3d only as light, and\n * one object in three finishes (titanium, ceramic, soft-touch polymer) under\n * one rig: key, fill and rim, soft shadows, occlusion and a softbox\n * reflection. Hanken Grotesk through var(--font-hanken) for display over\n * Geist. Respects prefers-reduced-motion. No dependencies beyond React.\n * Paste it as its own file: it repeats the kit's small helpers, which would\n * clash in one module. Load Hanken Grotesk with next/font (variable:\n * \"--font-hanken\") on a parent, or from Google Fonts or @fontsource.\n */\n\nconst DISPLAY = 'var(--font-hanken, \"Hanken Grotesk\", var(--font-sans, \"Geist\"), ui-sans-serif, system-ui, sans-serif)';\n\nconst EASE = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\nfunction subscribeMotion(cb: () => void) {\n  const m = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** True when the visitor asked for reduced motion. False on the server. */\nfunction useReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** The element that scrolls: null for the window, or the site itself in \"self\" mode. */\nconst ScrollRootContext = createContext<RefObject<HTMLElement | null> | null>(null);\n\n/** How far an element has come through the view: 0 as its top meets the bottom, 1 as its bottom meets the top. */\nfunction throughView(el: HTMLElement, root: HTMLElement | null): number {\n  const r = el.getBoundingClientRect();\n  const top = root ? root.getBoundingClientRect().top : 0;\n  const h = root ? root.clientHeight : window.innerHeight;\n  return clamp((top + h - r.top) / Math.max(1, h + r.height), 0, 1);\n}\n\n/** Calls `fn` on scroll (of the scroll root or the window) and on resize; returns the cleanup. */\nfunction onScrollOf(root: HTMLElement | null, fn: () => void): () => void {\n  const target: HTMLElement | Window = root ?? window;\n  target.addEventListener(\"scroll\", fn, { passive: true });\n  window.addEventListener(\"resize\", fn);\n  return () => {\n    target.removeEventListener(\"scroll\", fn);\n    window.removeEventListener(\"resize\", fn);\n  };\n}\n\nconst ANA_P = 900;\n\n/** A fixed depth per letter, -1 to 1 (from its index, so it is the same on every visit). */\nfunction depthOf(i: number): number {\n  const x = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;\n  return (x - Math.floor(x)) * 2 - 1;\n}\n\nfunction smooth01(x: number): number {\n  const t = clamp(x, 0, 1);\n  return t * t * (3 - 2 * t);\n}\n\nexport type AnamorphicHeadingProps = {\n  /** The heading, one entry per line. */\n  lines: string[];\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\";\n  /** How far the letters stand apart in depth (1 = up to 240px either side). */\n  depth?: number;\n  /** How far round the view starts, degrees. */\n  angle?: number;\n  /** Where the reveal is, 0-1, instead of the heading's place on the screen. */\n  progress?: number;\n  className?: string;\n  style?: CSSProperties;\n};\n\n/**\n * A heading that only reads from the right angle. Its letters stand at\n * different depths (up to 240px in front of and behind the page), each scaled\n * and shifted so that from straight ahead they line up exactly into the words;\n * from anywhere else they scatter in parallax. As the heading comes up the\n * screen the view swings from 36 degrees round to square and the words\n * resolve; the pointer leans the view a few degrees and they part again. Real\n * text, named once for screen readers; reduced motion shows it square.\n */\nexport function AnamorphicHeading({ lines, as = \"h2\", depth = 1, angle = 36, progress, className, style }: AnamorphicHeadingProps) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const groupRef = useRef<HTMLSpanElement>(null);\n  const scrollRoot = useContext(ScrollRootContext);\n  const reduced = useReducedMotion();\n  const [view, setView] = useState({ q: progress ?? 0, px: 0, py: 0 });\n\n  // Place every letter: at its depth, scaled back to its own size and shifted back onto its own spot as seen from the front.\n  useEffect(() => {\n    const group = groupRef.current;\n    if (!group) return;\n    const place = () => {\n      const cx = group.offsetWidth / 2;\n      const cy = group.offsetHeight / 2;\n      group.querySelectorAll<HTMLElement>(\"[data-s3-g]\").forEach((el, i) => {\n        const z = depthOf(i) * 240 * depth;\n        const s = (ANA_P - z) / ANA_P;\n        const x = el.offsetLeft + el.offsetWidth / 2 - cx;\n        const y = el.offsetTop + el.offsetHeight / 2 - cy;\n        el.style.transform = `translate3d(${(x * (s - 1)).toFixed(2)}px, ${(y * (s - 1)).toFixed(2)}px, ${z.toFixed(1)}px) scale(${s.toFixed(4)})`;\n      });\n    };\n    place();\n    const ro = new ResizeObserver(place);\n    ro.observe(group);\n    document.fonts?.ready.then(place);\n    return () => ro.disconnect();\n  }, [lines, depth]);\n\n  // The view swings round to square as the heading rises to the middle of the screen.\n  useEffect(() => {\n    if (progress !== undefined) return;\n    const root = scrollRoot?.current ?? null;\n    const update = () => {\n      const el = hostRef.current;\n      if (el) setView((v) => ({ ...v, q: throughView(el, root) }));\n    };\n    const id = requestAnimationFrame(update);\n    const off = onScrollOf(root, update);\n    return () => {\n      cancelAnimationFrame(id);\n      off();\n    };\n  }, [progress, scrollRoot]);\n\n  const q = progress ?? view.q;\n  const turn = reduced ? 0 : angle * (1 - smooth01(q * 2.8 - 0.2)) + view.px * 10;\n  const tilt = reduced ? 0 : -view.py * 6;\n  const Tag = as;\n  return (\n    <div\n      ref={hostRef}\n      className={\"relative \" + (className ?? \"\")}\n      style={style}\n      onPointerMove={(e) => {\n        if (reduced) return;\n        const r = e.currentTarget.getBoundingClientRect();\n        setView((v) => ({ ...v, px: (e.clientX - r.left) / r.width - 0.5, py: (e.clientY - r.top) / r.height - 0.5 }));\n      }}\n      onPointerLeave={() => setView((v) => ({ ...v, px: 0, py: 0 }))}\n    >\n      <Tag aria-label={lines.join(\" \")} className=\"m-0 font-bold\" style={{ fontFamily: DISPLAY, perspective: ANA_P }}>\n        <span ref={groupRef} aria-hidden className=\"relative block\" style={{ transformStyle: \"preserve-3d\", transform: `rotateY(${turn.toFixed(2)}deg) rotateX(${tilt.toFixed(2)}deg)`, transition: `transform 260ms ${EASE}` }}>\n          {lines.map((line, li) => (\n            <span key={li} className=\"block whitespace-nowrap\" style={{ transformStyle: \"preserve-3d\" }}>\n              {Array.from(line).map((ch, ci) => (\n                <span key={ci} data-s3-g=\"\" className=\"inline-block whitespace-pre\" style={{ transformStyle: \"preserve-3d\" }}>\n                  {ch}\n                </span>\n              ))}\n            </span>\n          ))}\n        </span>\n      </Tag>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/anamorphic-heading.tsx"}],"meta":{"tier":"free","tags":["text effect","3d","css 3d","anamorphic","scroll","parallax","heading"],"href":"https://viberdy.dev/library/anamorphic-heading","aiPrompt":"Build a React + Tailwind heading, `AnamorphicHeading({ lines, as, depth, angle, progress, className, style })`.\n\nRender each letter as an inline-block span inside nowrap line spans inside a relative preserve-3d group, with perspective 900px on the heading element and preserve-3d on every level down to the letters. After layout (and on resize and font load) give each letter a fixed depth z = d(i) x 240 x depth, where d is a hash of its index in -1..1, and set transform: translate3d(x(s-1), y(s-1), z) scale(s) with s = (900 - z)/900 and (x, y) the letter's centre relative to the group's centre (from offsetLeft/offsetTop), which is what lines it up from the front. Rotate the group by rotateY(angle x (1 - smoothstep(q x 2.8 - 0.2)) + pointer x 10) rotateX(-pointer y x 6), 260ms, where q is how far the heading has come through the view (or the progress prop). aria-label on the heading; reduced motion holds it square.\n\nSpatial 3D grammar: a dark studio, ground #0a0a0d (panels #101114, raised #16171b, ink #f2f1ee at 92/60/38%, hairlines at 8% and 16%), and one accent, anodised copper #ff7a3d, used only as light (a status arc, a detent marker, an active underglow), never as a fill. One object in three finishes, as material parameters: titanium (base #b9bcc2, gloss 0.62, specular 0.8, fully metallic), ceramic (#ece7dd, gloss 0.4, specular 0.25, a little subsurface wrap) and soft-touch polymer (#1b1c20, gloss 0.3, specular 0.12, the rim kept up so it holds against the ground). One rig for everything: a warm key from (0.55, 0.65, 0.42) at 1.15, a cool fill from (-0.4, -0.15, 0.55) at 0.3, a rim from behind at 0.65, soft shadows (k 16), five-tap occlusion and a studio reflection (a big softbox overhead left, a strip behind right), in linear light with a filmic curve and a 1/255 dither. Parts are blended with small smooth-min radii (8-15% of the smaller part) so the object reads as machined, not melted. Hanken Grotesk through var(--font-hanken, \"Hanken Grotesk\", ...) at 600-800 for display, Geist for reading, Geist Mono uppercase for readouts. Radii 24px panels, 20px cards, 14px controls, the dial full round. Motion: detent springs (stiffness 340, damping 26, ~380ms), orbit inertia that decays in ~270ms, an idle drift of 6 degrees a second on the page clock after 2.5s of stillness, hovers and presses in 90-420ms. Respect prefers-reduced-motion (a still three-quarter view you can still turn by hand; no drift, no scatter). No dependencies beyond React.","license":"https://viberdy.dev/license"}}