{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scroll-burn-text", "type": "registry:ui", "title": "Scroll Burn Text", "description": "A manifesto that comes at the reader and burns off. Each block grows toward the lens, splits into red and cyan at the edges and is eaten away glyph by glyph from the middle out, uncovering the next block standing behind it. Film grain over the whole frame, no animation library.", "files": [ { "path": "registry/ruixenui/scroll-burn-text.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrollBurnTextProps {\n /**\n * The blocks, read in order. Each one comes up out of the dark, passes the\n * lens and burns off, uncovering the next one standing behind it.\n */\n sections: string[];\n /** Line shown on the opening frame, before the first block is close enough to read. Fades out on the first flick of scroll. */\n hint?: React.ReactNode;\n /** Scroll distance each block gets. Taller is slower. Default `\"170vh\"`. */\n runway?: string;\n /** Scrollable ancestor to track instead of the page — pass this when pinning inside a bounded panel. */\n container?: React.RefObject;\n className?: string;\n}\n\n/**\n * Film grain, as a tiled SVG rather than a bitmap: it is the one texture here\n * that has to sit over the whole frame, and a few hundred bytes of turbulence\n * beats shipping a PNG large enough not to visibly repeat. The gamma on alpha\n * is what keeps it grain — raw turbulence averages half opaque, which is a grey\n * wash over the frame rather than specks on it.\n */\nconst GRAIN =\n \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='gamma' exponent='4'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E\\\")\";\n\n/** Progress through a block's own slot at which it starts to burn. */\nconst BURN_AT = 0.62;\n/** How much of the slot the burn takes to eat the block whole. */\nconst BURN_SPAN = 0.38;\n/** Slots of approach before the first block reaches the front. */\nconst LEAD = 0.7;\n/** Alpha of a block still standing behind the one up front. */\nconst DIM = 0.3;\n/**\n * How far into its own fade the first block already is on the opening frame.\n * Without it the runway opens on an empty frame: the first block sits exactly\n * at the start of its ramp, which is zero, and there is nothing to scroll\n * toward. A shape this faint at the far end of the room is the whole cue.\n */\nconst OPEN = 0.22;\n/** Distance a block is born at, in units of the distance it is read at. */\nconst FAR = 4;\n/** Distance it has closed to by the time it is gone — a quarter of reading distance is four times the size. */\nconst NEAR = 0.25;\n/**\n * Burn a single glyph fades over. Kept in step with the `0.09` in the glyph's\n * own opacity, which has to be a literal so Tailwind can see the class.\n */\nconst RAMP = 0.09;\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\n/**\n * Tracks `prefers-reduced-motion`. Straight off matchMedia rather than out of an\n * animation library — the burn writes its own styles, so a motion dependency\n * would be carried for this one boolean.\n */\nfunction useReducedMotion() {\n const [reduce, setReduce] = React.useState(false);\n React.useEffect(() => {\n const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n const read = () => setReduce(query.matches);\n read();\n query.addEventListener(\"change\", read);\n return () => query.removeEventListener(\"change\", read);\n }, []);\n return reduce;\n}\n\nexport function ScrollBurnText({\n sections,\n hint = \"scroll down\",\n runway = \"170vh\",\n container,\n className,\n}: ScrollBurnTextProps) {\n const prefersReducedMotion = useReducedMotion();\n const runwayRef = React.useRef(null);\n const counterRef = React.useRef(null);\n const hintRef = React.useRef(null);\n const blockRefs = React.useRef<(HTMLParagraphElement | null)[]>([]);\n\n const count = sections.length;\n\n // Read inside the scroll handler so retyping the copy does not tear the\n // listener down and rebuild it.\n const total = React.useRef(count);\n total.current = count;\n\n React.useEffect(() => {\n if (prefersReducedMotion) return;\n const el = runwayRef.current;\n if (!el) return;\n const containerEl = container?.current ?? null;\n const win = el.ownerDocument.defaultView ?? window;\n const scroller: HTMLElement | Window = containerEl ?? win;\n\n // Where a glyph sits in its block is a wrap-time fact, so the burn order is\n // measured once rather than derived from the character index: index order\n // would eat the copy in reading order, which is a wipe, not a burn.\n const measure = () => {\n blockRefs.current.forEach((block) => {\n if (!block) return;\n const w = block.offsetWidth || 1;\n const h = block.offsetHeight || 1;\n (Array.from(block.children) as HTMLElement[]).forEach((node) => {\n const x = (node.offsetLeft + node.offsetWidth / 2) / w;\n const y = (node.offsetTop + node.offsetHeight / 2) / h;\n // Two crossed waves instead of a noise field: they cost two sines and\n // land their blobs at the scale of a few glyphs, which is the bite a\n // real burn takes. A per-glyph random would give static, not holes.\n const blob =\n 0.5 +\n 0.28 * Math.sin(x * 11.3 + y * 6.1 + 1.7) +\n 0.22 * Math.sin(x * 5.7 - y * 13.9 + 4.2);\n // Middle of the block goes first and the corners hold out longest,\n // so the copy is eaten from the inside the way paper takes a flame.\n const middle = Math.hypot(x - 0.5, (y - 0.5) * 1.15) / 0.62;\n node.style.setProperty(\n \"--t\",\n `${clamp01(0.05 + 0.55 * middle + 0.45 * blob)}`,\n );\n });\n });\n };\n\n // Only the block that is actually burning needs its progress rewritten. The\n // rest hold at 0 or 1, and writing those every frame would recalculate a few\n // hundred glyph opacities for nothing.\n const burnt: number[] = [];\n let active = -1;\n let raf = 0;\n\n const update = () => {\n raf = 0;\n const rect = el.getBoundingClientRect();\n const viewport = containerEl ? containerEl.clientHeight : win.innerHeight;\n const top = containerEl\n ? rect.top - containerEl.getBoundingClientRect().top\n : rect.top;\n const p = clamp01(-top / (rect.height - viewport || 1));\n\n const count = total.current;\n // One slot per block. The runway stops with the last block at the moment\n // its burn would start, so the piece ends on that copy whole rather than\n // on a frame of ash.\n const t = -LEAD + p * (count - 1 + LEAD + BURN_AT);\n let front = 0;\n\n blockRefs.current.forEach((block, i) => {\n const wrap = block?.parentElement;\n if (!block || !wrap) return;\n const q = t - i;\n if (q > 1) front = Math.min(i + 1, count - 1);\n\n const alpha =\n clamp01((q + LEAD + OPEN) / 0.45) *\n (DIM + (1 - DIM) * clamp01(q / 0.45));\n\n // Nothing to paint before it arrives, and nothing left of it once the\n // burn has run — the last block never reaches that, so this only ever\n // clears blocks that are already ash.\n if (alpha <= 0 || q > 1) {\n wrap.style.visibility = \"hidden\";\n return;\n }\n wrap.style.visibility = \"visible\";\n wrap.style.opacity = `${alpha}`;\n // A lens, not an easing. Distance falls at a steady rate and size is one\n // over distance, so a block creeps while it is far off and rushes once\n // it is close — the same curve anything coming at you actually follows.\n // Doubling at a fixed rate instead would read as a flat zoom.\n const depth = Math.max(\n FAR - ((FAR - NEAR) * (q + LEAD)) / (1 + LEAD),\n NEAR,\n );\n wrap.style.transform = `scale(${1 / depth})`;\n\n // Run past 1 by the width of a glyph's own fade, or the glyph holding\n // the highest threshold is still half lit when the burn is over.\n const burn = clamp01((q - BURN_AT) / BURN_SPAN) * (1 + RAMP);\n if (burnt[i] !== burn) {\n burnt[i] = burn;\n block.style.setProperty(\"--b\", `${burn}`);\n // The split follows this block's own burn, so the type comes apart\n // optically at the moment it comes apart physically — and the one\n // arriving behind it stays clean.\n block.style.setProperty(\"--ab\", `${0.35 + burn * 2.6}`);\n }\n });\n\n // Off by the time the first block is anywhere near readable.\n if (hintRef.current) {\n hintRef.current.style.opacity = `${clamp01(1 - p / 0.08)}`;\n }\n if (active !== front) {\n active = front;\n if (counterRef.current) {\n counterRef.current.textContent = `${String(front + 1).padStart(2, \"0\")} / ${String(count).padStart(2, \"0\")}`;\n }\n }\n };\n\n const onScroll = () => {\n if (!raf) raf = win.requestAnimationFrame(update);\n };\n const onResize = () => {\n measure();\n onScroll();\n };\n\n measure();\n update();\n scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n win.addEventListener(\"resize\", onResize);\n // The column re-wraps when the panel does, and every threshold is pinned to\n // where a glyph landed, so a resized panel needs a fresh measure even when\n // nothing scrolled.\n const ro = containerEl ? new ResizeObserver(onResize) : null;\n if (containerEl && ro) ro.observe(containerEl);\n\n return () => {\n scroller.removeEventListener(\"scroll\", onScroll);\n win.removeEventListener(\"resize\", onResize);\n ro?.disconnect();\n if (raf) win.cancelAnimationFrame(raf);\n };\n }, [prefersReducedMotion, container]);\n\n // `relative` so the glyphs measure against the block rather than against the\n // frame — they are laid out in the block, but their offsets are reported\n // against the nearest positioned ancestor.\n // The type is sized off the frame rather than off breakpoints, and off the\n // same number as the column: a block has to hold its share of the frame at\n // reading distance, and stepping the size while the column scales smoothly\n // leaves it a third of the height it should be between two breakpoints.\n const column =\n \"relative w-[min(84vw,36rem)] text-center text-[clamp(1.25rem,6.5vw,2.75rem)] font-bold leading-[1.05] tracking-tight text-foreground\";\n\n if (prefersReducedMotion) {\n return (\n
\n
\n {sections.map((body, i) => (\n

\n {body}\n

\n ))}\n
\n
\n );\n }\n\n return (\n
\n \n
\n \n\n {hint ? (\n \n {/* The rule under it is the direction. The word alone reads as a\n label on the frame rather than an instruction to the reader. */}\n \n {hint}\n \n
\n ) : null}\n\n {sections.map((body, i) => (\n
\n ))}\n\n \n\n {/* The visual layer is split to the glyph, which assistive tech reads\n as loose letters, so the copy is carried once more intact. */}\n

{sections.join(\" \")}

\n \n \n \n );\n}\n\nexport default ScrollBurnText;\n", "type": "registry:ui", "target": "components/ruixen/scroll-burn-text.tsx" } ] }