# Line Reveal

> Headlines rise into place, one line at a time.

Line Reveal is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called text reveal, split text, masked lines or block reveal.

Each line of text slides up from behind an invisible edge, or a bar of colour sweeps across and uncovers it, the next line a beat later. Lines follow the real wrapping at any width, links stay clickable, and reduced motion gets a gentle fade.

- Page: https://foundry44.dev/components/line-reveal/
- Category: [Text](https://foundry44.dev/library/text/)
- Interactions: scroll
- Dependencies: none
- Licence: [MIT, free for any project](https://foundry44.dev/license/)

## Settings

| Attribute | Default | What it is |
| --- | --- | --- |
| `data-f44-duration` | `700` | Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. |
| `data-f44-stagger` | `80` | Delay in ms between the start of one line and the next. 150 by default in block style. |
| `data-f44-block-color` | `""` | Block style: the bar's colour. Empty uses `--f44-line-reveal-block`, else the text colour. |
| `data-f44-direction` | `left` | Block style: the edge the bar grows from. "alternate" flips every other line. |
| `data-f44-keep` | `false` | Block style: the bar stays behind the text as a highlight band instead of leaving. |
| `data-f44-once` | `true` | Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. |
| `data-f44-variant` | `mask` | How lines appear: sliding up from a mask, or uncovered by a solid bar. |

## AI prompt

# Foundry44 · Line Reveal

Text lines slide up from a mask, or are uncovered by a sweeping colour bar, one after another when scrolled into view.

Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.

## Markup it expects

The element marked `data-f44="line-reveal"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `lineReveal()` or as props.

```html
<h2 data-f44="line-reveal" data-f44-variant="block" data-f44-stagger="100">We design gardens to be walked slowly, by the people who live with them.</h2>
```

## Options

| Option | Attribute | Type | Default | Description |
|---|---|---|---|---|
| `duration` | `data-f44-duration` | `number` | `700` | Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. |
| `stagger` | `data-f44-stagger` | `number` | `80` | Delay in ms between the start of one line and the next. 150 by default in block style. |
| `blockColor` | `data-f44-block-color` | `string` | `` | Block style: the bar's colour. Empty uses `--f44-line-reveal-block`, else the text colour. |
| `direction` | `data-f44-direction` | `"left" | "right" | "alternate"` | `left` | Block style: the edge the bar grows from. "alternate" flips every other line. |
| `keep` | `data-f44-keep` | `boolean` | `false` | Block style: the bar stays behind the text as a highlight band instead of leaving. |
| `once` | `data-f44-once` | `boolean` | `true` | Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. |
| `variant` | `data-f44-variant` | `"mask" | "block"` | `mask` | How lines appear: sliding up from a mask, or uncovered by a solid bar. |

## Setup

- **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/line-reveal/line-reveal.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="line-reveal"` element and reads the `data-f44-*` attributes above.
- **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/line-reveal.json` writes `lib/line-reveal-core.ts` and `components/ui/line-reveal.tsx`, then use `<LineReveal />` with the options as props.
- **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { LineReveal } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { lineReveal } from "@foundry44/core/line-reveal"`.
- **React / Next.js by hand**: save the code below as `lib/line-reveal-core.ts` and import it from there (`@/lib/line-reveal-core` where that alias exists). Don't name it `line-reveal.ts` next to a `LineReveal.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `lineReveal(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
- **Any other stack**: create on mount with `lineReveal(element, options)`, `update(options)` when they change, `destroy()` on unmount.

Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `lineReveal(element)` on each element.

## Pitfalls

- Respects prefers-reduced-motion: keep that behaviour.
- Lines are measured when the reveal starts. Resizing afterwards is fine: at rest the words are ordinary inline text.
- Words are wrapped in spans while the effect is active, so hyphenation and kerning across a space can differ slightly. destroy() puts back the original text nodes.
- Do not change the text while the component is mounted: wrapped words are not updated. In React, give the component a new key when the text changes.
- Until the script runs, the text is visible; it hides as soon as the component starts, then reveals when in view.
- Block style: the bars are absolutely positioned inside the element (set to position: relative if static). With keep, the band sits behind the text: pick a blockColor the text reads on (the default, the text colour, would hide it).

## Code: `line-reveal-core.ts`

```ts
// Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
/**
 * Foundry44 — line-reveal
 * Text lines appear one after another when the text scrolls into view. Two styles:
 * - "mask": each line slides up from behind a mask.
 * - "block": a solid bar grows across each line, the text appears under full cover,
 *   then the bar shrinks away toward the other edge (or stays as a highlight band).
 * Lines are the lines as the browser wrapped them, measured when the reveal starts,
 * so the effect follows any width or font.
 *
 * Self-contained: no imports, so this file can be copied as-is.
 */

export interface LineRevealOptions {
  /** Time in ms for one line to slide into place. In block style, the time for each half (cover, then uncover); 750 by default there. @range 150 2000 50 */
  duration?: number;
  /** Delay in ms between the start of one line and the next. 150 by default in block style. @range 0 300 10 */
  stagger?: number;
  /** Block style: the bar's colour. Empty uses `--f44-line-reveal-block`, else the text colour. */
  blockColor?: string;
  /** Block style: the edge the bar grows from. "alternate" flips every other line. */
  direction?: "left" | "right" | "alternate";
  /** Block style: the bar stays behind the text as a highlight band instead of leaving. */
  keep?: boolean;
  /** Play only the first time the text enters the viewport. Otherwise it hides again once fully offscreen and replays. */
  once?: boolean;
  /** How lines appear: sliding up from a mask, or uncovered by a solid bar. */
  variant?: "mask" | "block";
}

export interface LineRevealInstance {
  update(options: LineRevealOptions): void;
  destroy(): void;
}

export const lineRevealDefaults: Required<LineRevealOptions> = {
  duration: 700,
  stagger: 80,
  blockColor: "",
  direction: "left",
  keep: false,
  once: true,
  variant: "mask",
};

// Block style has its own pace: each half of the bar's sweep is a full movement.
const BLOCK_DURATION = 750;
const BLOCK_STAGGER = 150;

const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
// A strong in-out: the bar leaves slowly, rushes across, and settles at the edge.
const EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)";
// Further than 100% so descenders, which the mask lets through slightly, start fully hidden.
const HIDDEN_TRANSFORM = "translateY(125%)";
// The mask clips only the bottom edge, with a little room for descenders; the other sides
// are extended so accents and italic overhangs are never cut.
const MASK_CLIP = "inset(-0.5em -0.25em -0.12em -0.25em)";
const DEFAULT_BLOCK_COLOR = "var(--f44-line-reveal-block, currentColor)";

interface Line {
  left: number;
  top: number;
  width: number;
  height: number;
}

export function lineReveal(el: HTMLElement, options: LineRevealOptions = {}): LineRevealInstance {
  // What the caller set, kept apart from the defaults: duration and stagger default
  // differently in each style.
  let given: LineRevealOptions = { ...options };
  let opts = { ...lineRevealDefaults, ...given };

  if (typeof window === "undefined") return { update() {}, destroy() {} };
  // Reduced motion keeps the line-by-line rhythm but drops the movement and the bar: lines fade in.
  const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;

  // Each original text node and the nodes that replaced it, so destroy() can put back
  // the very same nodes (frameworks keep references to them).
  const splits: { original: Text; parts: Node[] }[] = [];
  const masks: HTMLSpanElement[] = [];
  const words: HTMLSpanElement[] = [];
  const bars: HTMLSpanElement[] = [];
  const savedPosition = el.style.position;
  const savedIsolation = el.style.isolation;
  let observer: IntersectionObserver | null = null;
  let resizeObserver: ResizeObserver | null = null;
  let cleanupTimer = 0;
  let shown = false;
  let played = false;
  let lastWidth = -1;

  const isBlock = () => opts.variant === "block" && !reduced;
  const duration = () => given.duration ?? (isBlock() ? BLOCK_DURATION : lineRevealDefaults.duration);
  const stagger = () => given.stagger ?? (isBlock() ? BLOCK_STAGGER : lineRevealDefaults.stagger);
  const fades = () => reduced || isBlock();
  const property = () => (fades() ? "opacity" : "transform");
  const hiddenValue = () => (fades() ? "0" : HIDDEN_TRANSFORM);
  const shownValue = () => (fades() ? "1" : "none");
  const barColor = () => opts.blockColor || DEFAULT_BLOCK_COLOR;
  const fromRight = (line: number) => opts.direction === "right" || (opts.direction === "alternate" && line % 2 === 1);

  // Every word gets its own span rather than regrouping words into line elements:
  // inline markup (links, <em>) stays exactly where it was. Words on the same line
  // share a delay, so they move as one line.
  const split = () => {
    const textNodes: Text[] = [];
    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
    for (let node = walker.nextNode(); node; node = walker.nextNode()) {
      if ((node as Text).data.trim()) textNodes.push(node as Text);
    }
    for (const original of textNodes) {
      const parts: Node[] = [];
      for (const chunk of original.data.split(/(\s+)/)) {
        if (!chunk) continue;
        if (/^\s+$/.test(chunk)) {
          parts.push(document.createTextNode(chunk));
          continue;
        }
        const mask = document.createElement("span");
        mask.style.display = "inline-block";
        if (!fades()) mask.style.clipPath = MASK_CLIP;
        const word = document.createElement("span");
        word.style.display = "inline-block";
        word.style.setProperty(property(), hiddenValue());
        word.textContent = chunk;
        mask.appendChild(word);
        parts.push(mask);
        masks.push(mask);
        words.push(word);
      }
      const fragment = document.createDocumentFragment();
      for (const part of parts) fragment.appendChild(part);
      original.parentNode?.replaceChild(fragment, original);
      splits.push({ original, parts });
    }
  };

  const unsplit = () => {
    for (const { original, parts } of splits) {
      const first = parts[0];
      first?.parentNode?.insertBefore(original, first);
      for (const part of parts) part.parentNode?.removeChild(part);
    }
    splits.length = 0;
    masks.length = 0;
    words.length = 0;
  };

  // Groups words by line (same top) and returns, for each word, its line index,
  // and for each line its box relative to the element.
  const measure = () => {
    const box = el.getBoundingClientRect();
    const lineOf: number[] = [];
    const lines: Line[] = [];
    let lineTop = Number.NEGATIVE_INFINITY;
    for (const mask of masks) {
      const r = mask.getBoundingClientRect();
      let current = lines[lines.length - 1];
      if (!current || r.top > lineTop + 1) {
        lineTop = r.top;
        current = { left: r.left, top: r.top, width: r.width, height: r.height };
        lines.push(current);
      } else {
        const right = Math.max(current.left + current.width, r.left + r.width);
        current.left = Math.min(current.left, r.left);
        current.width = right - current.left;
        current.height = Math.max(current.height, r.height);
      }
      lineOf.push(lines.length - 1);
    }
    for (const line of lines) {
      line.left -= box.left + el.clientLeft;
      line.top -= box.top + el.clientTop;
    }
    return { lineOf, lines };
  };

  const removeBars = () => {
    window.clearTimeout(cleanupTimer);
    for (const bar of bars) bar.remove();
    bars.length = 0;
  };

  // A bar is two nested spans: the outer one grows from one edge (cover), the inner one
  // shrinks toward the other edge (uncover). Their product is the visible bar, so each
  // half is a plain CSS transition with its own delay.
  const makeBar = (line: Line, index: number) => {
    const right = fromRight(index);
    const outer = document.createElement("span");
    outer.setAttribute("aria-hidden", "true");
    // Larger than the words, so ascenders and descenders never poke out at the switch
    // and the band frames them like a highlighter.
    const padX = Math.round(line.height * 0.08);
    const padY = Math.round(line.height * 0.12);
    Object.assign(outer.style, {
      position: "absolute",
      left: `${line.left - padX}px`,
      top: `${line.top - padY}px`,
      width: `${line.width + padX * 2}px`,
      height: `${line.height + padY * 2}px`,
      pointerEvents: "none",
      // In front while it hides the text; behind it when it stays as a band.
      zIndex: opts.keep ? "-1" : "1",
      transformOrigin: right ? "right center" : "left center",
    });
    const inner = document.createElement("span");
    Object.assign(inner.style, {
      display: "block",
      width: "100%",
      height: "100%",
      background: barColor(),
      transformOrigin: right ? "left center" : "right center",
    });
    outer.appendChild(inner);
    el.appendChild(outer);
    bars.push(outer);
    return { outer, inner };
  };

  const prepareElement = () => {
    if (getComputedStyle(el).position === "static") el.style.position = "relative";
    // The kept band sits behind the text but above the element's own background.
    if (opts.keep) el.style.isolation = "isolate";
  };

  const showBlock = () => {
    prepareElement();
    removeBars();
    const { lineOf, lines } = measure();
    const half = duration();
    const step = stagger();
    const made = lines.map((line, i) => makeBar(line, i));
    for (const { outer } of made) outer.style.transform = "scaleX(0)";
    // Flush the start state so the transitions below run from it.
    void el.offsetWidth;
    made.forEach(({ outer, inner }, i) => {
      const delay = i * step;
      outer.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay}ms`;
      outer.style.transform = "scaleX(1)";
      if (!opts.keep) {
        inner.style.transition = `transform ${half}ms ${EASE_IN_OUT} ${delay + half}ms`;
        inner.style.transform = "scaleX(0)";
      }
    });
    // The text switches on at full cover, hidden by the bar at that instant.
    words.forEach((word, i) => {
      word.style.transition = `opacity 0ms linear ${(lineOf[i] ?? 0) * step + half}ms`;
      word.style.opacity = "1";
    });
    if (!opts.keep) cleanupTimer = window.setTimeout(removeBars, (lines.length - 1) * step + half * 2 + 100);
    lastWidth = el.clientWidth;
  };

  // A kept band follows the text when it rewraps: rebuilt at rest, without animating.
  const relayoutBands = () => {
    if (!shown || !opts.keep || !isBlock()) return;
    if (el.clientWidth === lastWidth) return;
    lastWidth = el.clientWidth;
    removeBars();
    const { lines } = measure();
    lines.forEach((line, i) => {
      makeBar(line, i);
    });
  };

  const show = () => {
    if (shown) return;
    shown = true;
    played = true;
    if (isBlock()) {
      showBlock();
      return;
    }
    // Measuring also flushes the hidden state, so the transition below has a start value.
    const { lineOf } = measure();
    const prop = property();
    words.forEach((word, i) => {
      word.style.transition = `${prop} ${duration()}ms ${EASE_OUT} ${(lineOf[i] ?? 0) * stagger()}ms`;
      word.style.setProperty(prop, shownValue());
    });
  };

  const hide = () => {
    if (!shown) return;
    shown = false;
    removeBars();
    for (const word of words) {
      word.style.transition = "none";
      word.style.setProperty(property(), hiddenValue());
    }
  };

  const bind = () => {
    if (isBlock() && opts.keep && typeof ResizeObserver !== "undefined") {
      resizeObserver = new ResizeObserver(relayoutBands);
      resizeObserver.observe(el);
    }
    if (opts.once && played) return;
    if (typeof IntersectionObserver === "undefined") {
      show();
      return;
    }
    observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            show();
            if (opts.once) {
              observer?.disconnect();
              observer = null;
            }
            continue;
          }
          // The observed area is shrunk at the bottom, so "not intersecting" can still be
          // partly on screen: hide only once the text is entirely out of sight.
          const { top, bottom } = entry.boundingClientRect;
          if (bottom <= 0 || top >= window.innerHeight) hide();
        }
      },
      // Wait until the text is a little inside the viewport so the start is actually seen.
      { rootMargin: "0px 0px -10% 0px" },
    );
    observer.observe(el);
  };

  const unbind = () => {
    observer?.disconnect();
    observer = null;
    resizeObserver?.disconnect();
    resizeObserver = null;
  };

  const restoreElement = () => {
    el.style.position = savedPosition;
    el.style.isolation = savedIsolation;
  };

  split();
  bind();

  return {
    update(next) {
      const previous = opts;
      given = { ...given, ...next };
      opts = { ...lineRevealDefaults, ...given };
      // The style decides how words are wrapped and how bars are built: start over,
      // and replay at once if the text was already revealed.
      if (opts.variant !== previous.variant || opts.direction !== previous.direction || opts.keep !== previous.keep) {
        const wasShown = shown;
        unbind();
        removeBars();
        unsplit();
        restoreElement();
        shown = false;
        played = false;
        split();
        if (wasShown) show();
        bind();
        return;
      }
      if (opts.blockColor !== previous.blockColor) {
        for (const bar of bars) {
          const inner = bar.firstElementChild as HTMLElement | null;
          if (inner) inner.style.background = barColor();
        }
      }
      // duration and stagger are read when a reveal starts; only `once` changes the wiring.
      if (opts.once !== previous.once) {
        unbind();
        bind();
      }
    },
    destroy() {
      unbind();
      removeBars();
      unsplit();
      restoreElement();
    },
  };
}
```
