Reveal
Lines are found by measuring, not guessing: the text is split into words, each word's offsetTop is read once, and words sharing a top form a line — so the reveal follows the ACTUAL wrap at the current viewport width and font size, and re-measures when either changes. Splitting on a character count misaligns the moment a responsive heading rewraps. Each line sits in a clipping wrapper so it rises from behind a hard edge rather than fading, which is what makes it read as typographic rather than as a generic fade-up.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Reveal } from "@/components/reveal"; <Reveal> Systems that think, built from first principles.</Reveal>Customise
As a fraction of the line's own height. 1 starts fully below the mask.
Share of the block that must be in view.
The component
components/craft/motion/Reveal.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef, useState } from "react"; /** * Text rising into place line by line, masked behind its own baseline. * * Lines are found by measuring, not by guessing. The text is split into words, * each word's offsetTop is read once, and words sharing a top are a line — * so the reveal follows the ACTUAL wrap at the actual viewport width and font * size. Splitting on a character count, or on explicit line breaks, misaligns * the moment anything reflows: a responsive heading rewraps and the animation * suddenly cuts mid-phrase. * * Each line sits in a clipping wrapper, so it rises from behind a hard edge * rather than fading in. That mask is what makes the effect read as * typographic rather than as a generic fade-up. * * The whole string stays in the accessibility tree as a single node: the split * wrappers are aria-hidden and an sr-only copy carries the real text. * Splitting text into per-word spans without this makes some screen readers * announce it one word at a time, with a pause between each. */ export interface RevealProps { children: string; /** Seconds between consecutive lines. */ stagger?: number; /** Seconds for one line's travel. */ duration?: number; /** How far each line travels, as a fraction of its own height. */ distance?: number; /** Fires once when this share of the block is in view. */ threshold?: number; as?: "h1" | "h2" | "h3" | "p" | "span"; still?: boolean; className?: string;} export function Reveal({ children, stagger = 0.08, duration = 0.9, distance = 1, threshold = 0.3, as: Tag = "p", still = false, className = "",}: RevealProps) { const hostRef = useRef<HTMLDivElement>(null); const [lines, setLines] = useState<string[] | null>(null); const [shown, setShown] = useState(false); const reduced = still || (typeof window !== "undefined" && typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); /** * Measure the real wrap: render the words, read their offsets, group by top. * Re-runs on resize, because the wrap changes with the container. */ useEffect(() => { if (reduced) return; const host = hostRef.current; if (!host) return; const measure = () => { const probe = document.createElement("div"); // Inherits every typographic property that affects wrapping, so the // measurement matches what will actually render. probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;white-space:normal;"; probe.style.width = `${host.clientWidth}px`; probe.style.font = getComputedStyle(host).font; probe.style.letterSpacing = getComputedStyle(host).letterSpacing; const words = children.split(/\s+/).filter(Boolean); probe.innerHTML = words.map((w) => `<span>${w}</span>`).join(" "); host.appendChild(probe); const grouped: string[] = []; let currentTop: number | null = null; probe.querySelectorAll("span").forEach((span, i) => { const top = (span as HTMLElement).offsetTop; if (currentTop === null || top !== currentTop) { currentTop = top; grouped.push(words[i]); } else { grouped[grouped.length - 1] += ` ${words[i]}`; } }); host.removeChild(probe); const next = grouped.length ? grouped : [children]; // Only commit when the wrap actually changed. Setting a fresh array // every time re-renders, which can resize the host, which fires the // ResizeObserver below, which measures again — a loop that never // settles and leaves the reveal permanently mid-flight. setLines((current) => current && current.length === next.length && current.every((line, i) => line === next[i]) ? current : next, ); }; // Deferred a frame so fonts and layout have settled — measuring during // the same commit gives the fallback font's wrap, which is wrong. const id = requestAnimationFrame(measure); const observer = new ResizeObserver(() => requestAnimationFrame(measure)); observer.observe(host); document.fonts?.ready.then(measure).catch(() => {}); return () => { cancelAnimationFrame(id); observer.disconnect(); }; }, [children, reduced]); useEffect(() => { if (reduced) return; const host = hostRef.current; if (!host) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry.isIntersecting) return; setShown(true); // Once revealed it stays revealed; replaying on every scroll past // turns a flourish into a distraction. observer.disconnect(); }, { threshold }, ); observer.observe(host); /** * Direct fallback, because the observer alone is not enough. * * IntersectionObserver reports nothing while the document is hidden — a * background tab, a prerender, or an embedded view. Text placed above the * fold then stays invisible until the tab is focused AND something moves, * which presents as "the reveal just does not run". Measuring the rect * once covers the case the observer structurally cannot. * * A timeout, deliberately, not requestAnimationFrame: rAF does not fire * at all in a hidden document, which is precisely the situation this * exists to rescue. */ const id = window.setTimeout(() => { const box = host.getBoundingClientRect(); const visible = Math.min(box.bottom, window.innerHeight) - Math.max(box.top, 0); if (box.height > 0 && visible / box.height >= Math.min(threshold, 0.99)) { setShown(true); observer.disconnect(); } }, 0); return () => { window.clearTimeout(id); observer.disconnect(); }; }, [threshold, reduced]); if (reduced) { return <Tag className={className}>{children}</Tag>; } return ( <Tag className={className}> {/* The real string, announced once, and what select-and-copy yields. */} <span className="sr-only">{children}</span> <div ref={hostRef} aria-hidden> {lines === null ? ( // Pre-measurement: render the text invisibly so the block already // occupies its final height and nothing below it jumps when the // lines appear. <span style={{ opacity: 0 }}>{children}</span> ) : ( lines.map((line, i) => ( <span key={`${line}-${i}`} className="block overflow-hidden"> <span className="block will-change-transform" style={{ transform: shown ? "translateY(0)" : `translateY(${distance * 100}%)`, opacity: shown ? 1 : 0, transition: `transform ${duration}s cubic-bezier(0.16,1,0.3,1) ${i * stagger}s, opacity ${duration * 0.6}s linear ${i * stagger}s`, }} > {line} </span> </span> )) )} </div> </Tag> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+2.2KB gzipped · no new dependencies
- Runs on
Compositor
Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.
- Reduced motion
Renders the text plainly with no split, no wrappers and no observers — the component returns early before any of that exists.
- Accessibility
- The whole string sits in an sr-only node, so it is announced once rather than word by word.
- The split wrappers are aria-hidden.
- Before measuring, the text renders invisibly at full size so the block already occupies its final height and nothing below it jumps.
- Select-and-copy yields the real text.
- Watch out
- Measures with a hidden probe element on mount and on resize. Cheap, but it is layout work — do not put a hundred of these on one page.
- Takes a plain string, not arbitrary children. Measuring the wrap of nested elements reliably is a much bigger problem.