Skip to content
Motion

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Reveal } from "@/components/reveal"; <Reveal>  Systems that think, built from first principles.</Reveal>

Customise

0.08s
0.9s
1

As a fraction of the line's own height. 1 starts fully below the mask.

0.3

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.