Skip to content

Words or wrapped lines lift out of a blur once, text left intact.

01Preview

v4.12

Branch previews for every pull request

Each push to a branch now builds its own preview with a stable URL, posted on the pull request and torn down the moment it merges.

38s median buildRead the changelog

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { TextReveal } from "@/components/ui/text-reveal";

<TextReveal as="h1" className="text-[32px] font-medium tracking-[-0.025em]">
  Branch previews for every pull request
</TextReveal>

// Paragraphs read better line by line, a beat after the title.
<TextReveal as="p" by="line" delay={0.2}>
  Each push builds its own preview with a stable URL.
</TextReveal>

04Source

"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import { ease, stagger as staggers } from "@/lib/motion";

type Tag = "h1" | "h2" | "h3" | "h4" | "p" | "span" | "div";

export type TextRevealProps = Omit<React.HTMLAttributes<HTMLElement>, "children"> & {
  /** The text. Plain text only, so it can be split into words. */
  children: string;
  /** The element to render. A heading keeps its heading semantics. */
  as?: Tag;
  /** Reveal word by word (headings), or line by line as the text actually wraps (paragraphs). */
  by?: "word" | "line";
  /** Reveal when it scrolls into view, or as soon as it mounts (above the fold). */
  trigger?: "view" | "mount";
  /** Seconds before the first unit. */
  delay?: number;
  /** Seconds between units. Long text compresses it so the last unit never trails behind `maxSpread`. */
  stagger?: number;
  /** Seconds from the first unit starting to the last one starting, at most. */
  maxSpread?: number;
  /** Seconds for each unit's entrance. */
  duration?: number;
  /** How far each unit lifts, as a CSS length. Em keeps it proportional to the type size. */
  distance?: string;
  /** The blur it clears from, as a CSS length. */
  blur?: string;
  /** The scroll container, when it isn't the page. */
  root?: React.RefObject<Element | null>;
  /** Called once, when the last unit has settled. */
  onRevealed?: () => void;
  ref?: React.Ref<HTMLElement>;
};

/**
 * Words (or lines) lift out of a blur in reading order, once, on first view.
 * The whole string stays in the element for screen readers, find-in-page and
 * copy; the animated split is decoration on top. With reduced motion, or
 * without JavaScript, the text is simply there.
 */
export function TextReveal({
  children,
  as: Tag = "span",
  by = "word",
  trigger = "view",
  delay = 0,
  stagger,
  maxSpread = 0.6,
  duration,
  distance = "0.3em",
  blur = "0.2em",
  root,
  onRevealed,
  className,
  ref,
  ...rest
}: TextRevealProps) {
  const reduce = useReducedMotion();
  const self = useRef<HTMLElement>(null);
  const opts = useRef({ by, delay, stagger, maxSpread, duration, distance, blur, onRevealed });
  useEffect(() => {
    opts.current = { by, delay, stagger, maxSpread, duration, distance, blur, onRevealed };
  });

  useEffect(() => {
    const el = self.current;
    if (!el || el.hasAttribute("data-revealed")) return;
    const show = () => {
      el.setAttribute("data-revealed", "");
      opts.current.onRevealed?.();
    };
    if (reduce) return show();

    const running: Animation[] = [];
    const play = () => {
      const o = opts.current;
      const units = Array.from(el.querySelectorAll<HTMLElement>("[data-unit]"));
      // Lines are whatever the browser wrapped at this width, read at the moment
      // of playing, so a resize before the reveal never groups words wrongly.
      const groups: HTMLElement[][] = [];
      if (o.by === "line") {
        let top = 0;
        for (const u of units) {
          if (!groups.length || Math.abs(u.offsetTop - top) > 2) groups.push([]);
          top = u.offsetTop;
          groups[groups.length - 1].push(u);
        }
      } else units.forEach((u) => groups.push([u]));

      const each = o.stagger ?? (o.by === "line" ? staggers.lines : staggers.words);
      const step = groups.length > 1 ? Math.min(each, o.maxSpread / (groups.length - 1)) : 0;
      const length = (o.duration ?? (o.by === "line" ? 0.8 : 0.64)) * 1000;
      groups.forEach((group, i) =>
        group.forEach((u) =>
          running.push(
            // Web Animations with fill "backwards": held hidden through the delay,
            // and nothing left on the element afterwards.
            u.animate(
              [
                { opacity: 0, transform: `translateY(${o.distance})`, filter: `blur(${o.blur})` },
                { opacity: 1, transform: "none", filter: "blur(0px)" },
              ],
              { duration: length, delay: (o.delay + i * step) * 1000, easing: `cubic-bezier(${ease.out.join(",")})`, fill: "backwards" },
            ),
          ),
        ),
      );
      el.setAttribute("data-revealed", "");
      running.at(-1)?.finished.then(() => opts.current.onRevealed?.(), () => {});
    };

    if (trigger === "mount") {
      play();
      return () => running.forEach((a) => a.cancel());
    }

    const io = new IntersectionObserver(
      ([entry]) => {
        // Scrolled past before it was seen (an anchor jump, a restored scroll): no show.
        const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);
        if (!entry.isIntersecting && !above) return;
        io.disconnect();
        if (above) show();
        else play();
      },
      { root: root?.current ?? null, rootMargin: "0px 0px -10% 0px" },
    );
    io.observe(el);
    return () => {
      io.disconnect();
      running.forEach((a) => a.cancel());
    };
  }, [reduce, trigger, root]);

  // Whitespace stays as real text between the word boxes, so the browser wraps
  // (and balances) the line exactly as it would the plain string.
  const parts = children.split(/(\s+)/).filter(Boolean);

  return (
    <>
      <Tag
        ref={(node: HTMLElement | null) => {
          self.current = node;
          if (typeof ref === "function") return ref(node);
          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;
        }}
        data-text-reveal=""
        data-by={by}
        className={cn(
          // Hidden until revealed, only when motion is welcome; reduced motion never hides anything.
          "motion-safe:[&:not([data-revealed])_[data-unit]]:opacity-0 print:[&_[data-unit]]:opacity-100!",
          className,
        )}
        {...rest}
      >
        <span className="sr-only select-none">{children}</span>
        <span aria-hidden>
          {parts.map((part, i) =>
            /^\s+$/.test(part) ? (
              part
            ) : (
              <span key={i} data-unit="" className="inline-block">
                {part}
              </span>
            ),
          )}
        </span>
      </Tag>
      {/* Without JavaScript nothing would ever reveal, so nothing hides. */}
      <noscript>
        <style>{"[data-text-reveal] [data-unit]{opacity:1!important}"}</style>
      </noscript>
    </>
  );
}

05Props

PropTypeDefaultDescription
children*stringThe text to reveal. Plain text, so it can be split into words.
as"h1" | "h2" | "h3" | "h4" | "p" | "span" | "div""span"The element to render; headings keep their level.
by"word" | "line""word"Stagger each word, or each line as the browser actually wrapped it.
trigger"view" | "mount""view"Play when it scrolls into view, or as soon as it mounts.
delaynumber0Seconds before the first word or line.
staggernumber0.03 (word) · 0.08 (line)Seconds between units.
maxSpreadnumber0.6Longest time from the first unit starting to the last. Long text compresses the stagger to fit.
durationnumber0.64 (word) · 0.8 (line)Seconds for each unit's entrance.
distancestring"0.3em"How far each unit lifts. In em so it scales with the type.
blurstring"0.2em"The blur each unit clears from.
rootRefObject<Element | null>The scroll container, when it isn't the page.
onRevealed() => voidCalled once the last unit has settled, or straight away when nothing animates.

06Notes

Behavior

  • Plays once per mount. Scrolled past before it was seen (an anchor jump, a restored scroll position), it shows without the performance.
  • Line mode groups words by where the browser wrapped them at the moment it plays, so balanced headings and any width group correctly.
  • Long passages compress the stagger so the last line never starts more than 0.6s after the first; nobody waits for the tail.
  • Whitespace stays real text between the word boxes, so wrapping, text-wrap: balance and copy-paste behave as with the plain string. Change the text later and it simply updates; give it a new key to reveal again.

Motion

  • Each unit rises 0.3em from a 0.2em blur at 0 opacity, 640ms per word or 800ms per line on the expo ease-out, 30ms or 80ms apart.
  • Web Animations with backwards fill: hidden through its delay, nothing left on the element afterwards to fight your styles.
  • Reduced motion never hides the text and plays nothing; without JavaScript a noscript rule keeps it visible.

Accessibility

  • The whole string sits in the element as visually hidden text, so the heading or paragraph is announced once, in full, with its real role.
  • The split words are aria-hidden decoration, and the hidden copy is unselectable so a copied selection doesn't repeat itself.