---
title: Line Reveal
description: Headings and paragraphs that rise line by line out of a mask.
category: Text
tier: free
url: https://ui.dimaac.com/components/line-reveal
registry: https://ui.dimaac.com/r/line-reveal.json
---

# Line Reveal

A heading or paragraph that is cut into lines and lifted out of a mask as it scrolls into view, the way editorial award sites open a page. Switch on highlight and the words start faint and fill in one by one as you scroll through the text. It uses GSAP SplitText with autoSplit, so the lines are cut again when the width changes or the web font loads, and everything is put back the way it was on unmount. The text stays real text: selectable, and read as one sentence by screen readers. It takes an optional scroller for inner scroll areas and shows the text as it is when the visitor prefers reduced motion.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/line-reveal.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/line-reveal`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/line-reveal.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { LineReveal } from "@/components/dimaac/line-reveal" and give its parent a size: it fills it.

## Usage

```tsx
import { LineReveal } from "@/components/dimaac/line-reveal";

<LineReveal children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `string` | - | The text to show. Plain text only (the lines are cut from it). |
| `as` | `"h1" \| "h2" \| "p"` | `"p"` | The tag to render: a page title, a section title or a paragraph. |
| `stagger` | `number` | `0.08` | Delay between one line starting and the next, in seconds. |
| `duration` | `number` | `1` | How long each line takes to rise, in seconds. |
| `highlight` | `boolean` | `false` | Fill the words in one by one as you scroll through the text. |
| `highlightColor` | `string` | `"#ff5a36"` | The colour a word turns when it is filled in. Empty keeps the text colour. |
| `start` | `string` | `"top 85%"` | When the reveal starts: "<element edge> <viewport edge>", e.g. "top 85%". |
| `scroller` | `string \| HTMLElement` | - | The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. |
| `className` | `string` | `""` | Extra classes: put the font size, weight and colour here. |

## Source

### line-reveal.tsx → `components/dimaac/line-reveal.tsx`

```tsx
"use client";

import { createElement, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

// A heading or paragraph that rises line by line out of a mask when it scrolls into view.
// Optionally the words start faint and fill in as you scroll, like reading along with your finger.
// The text stays real text: selectable, and read out as one sentence by screen readers.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

export type LineRevealProps = {
  /** The text to show. Plain text only (the lines are cut from it). */
  children: string;
  /** The tag to render: a page title, a section title or a paragraph. */
  as?: "h1" | "h2" | "p";
  /** Delay between one line starting and the next, in seconds. */
  stagger?: number;
  /** How long each line takes to rise, in seconds. */
  duration?: number;
  /** Fill the words in one by one as you scroll through the text. */
  highlight?: boolean;
  /** The colour a word turns when it is filled in. Empty keeps the text colour. */
  highlightColor?: string;
  /** When the reveal starts: "<element edge> <viewport edge>", e.g. "top 85%". */
  start?: string;
  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */
  scroller?: string | HTMLElement;
  /** Extra classes: put the font size, weight and colour here. */
  className?: string;
};

export function LineReveal({
  children,
  as = "p",
  stagger = 0.08,
  duration = 1,
  highlight = false,
  highlightColor = "#ff5a36",
  start = "top 85%",
  scroller,
  className = "",
}: LineRevealProps) {
  const textRef = useRef<HTMLElement>(null);

  useGSAP(
    () => {
      const element = textRef.current;
      if (!element) return;

      // The text is hidden until it is split, so you never see it jump. Show it again either way.
      element.style.visibility = "visible";

      // Reduced motion: leave the text exactly as written, fully readable
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      let fill: gsap.core.Tween | undefined;

      const split = SplitText.create(element, {
        type: highlight ? "lines,words" : "lines",
        mask: "lines",
        linesClass: "line-reveal-line",
        // Re-cut the lines when the width changes or the web font arrives
        autoSplit: true,
        onSplit(self) {
          // The mask clips to the line box, which cuts the tails of g, y and p. Give it a little room.
          self.masks.forEach((mask) => {
            const box = mask as HTMLElement;
            box.style.paddingBottom = "0.14em";
            box.style.marginBottom = "-0.14em";
          });

          // This runs again after every re-split, so drop the old fill first
          fill?.scrollTrigger?.kill();
          fill?.kill();

          if (highlight) {
            const color = highlightColor || getComputedStyle(element).color;
            // Words start faint and fill in as the scrollbar moves (scrub), one after another
            fill = gsap.fromTo(
              self.words,
              { opacity: 0.18 },
              {
                opacity: 1,
                color,
                ease: "none",
                stagger: 0.12,
                duration: 0.4,
                scrollTrigger: { trigger: element, scroller, start, end: "bottom 45%", scrub: 0.4 },
              },
            );
          }

          // Returning the tween lets SplitText keep it in step when it re-splits
          return gsap.from(self.lines, {
            yPercent: 115,
            rotate: 3,
            transformOrigin: "0% 100%",
            duration,
            ease: "power4.out",
            stagger,
            scrollTrigger: { trigger: element, scroller, start, toggleActions: "play none none none" },
          });
        },
      });

      return () => {
        fill?.scrollTrigger?.kill();
        fill?.kill();
        split.revert();
      };
    },
    { scope: textRef, dependencies: [children, as, stagger, duration, highlight, highlightColor, start, scroller], revertOnUpdate: true },
  );

  // The key makes React build a fresh element when the text or tag changes, because SplitText
  // rewrites the inside of the old one.
  return createElement(as, { ref: textRef, key: `${as}:${children}`, className, style: { visibility: "hidden" } }, children);
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { LineReveal, type LineRevealProps } from "@/components/dimaac/line-reveal";

// An editorial page made only of type. Every block uses the same playground settings.
// The big statement always fills in as you scroll; the "highlight" switch turns it on for the body text too.

const notes = [
  {
    number: "01",
    title: "Set in the mask",
    text: "Each line is cut out of the paragraph and sits just below the edge of its own window. When it enters the page it rises into view, one line after another, and settles with a long slow ease.",
  },
  {
    number: "02",
    title: "Cut again when it changes",
    text: "Resize the window, rotate the phone or wait for the font to arrive and the lines are measured again. Nothing breaks halfway, and the animation carries on from where it was.",
  },
  {
    number: "03",
    title: "Still just text",
    text: "Select it, search it, translate it. Screen readers get the sentence in one piece, and anyone who asks their device for less motion simply gets the words, fully visible.",
  },
];

const serif = "font-[family-name:Instrument_Serif,serif]";

export default function Demo(props: Partial<LineRevealProps>) {
  const { className: _className, children: _children, as: _as, ...shared } = props;
  return (
    <div className="min-h-dvh bg-[#f3f0eb] text-[#1c1917] dark:bg-[#0f0b0b] dark:text-[#e8ddd4]">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap" precedence="default" />

      <section className="flex min-h-[100dvh] flex-col justify-end px-6 pb-16 md:px-12 md:pb-24">
        <LineReveal {...shared} as="h1" start="top 100%" className={`max-w-[14ch] ${serif} text-[clamp(52px,11vw,176px)] leading-[0.92] tracking-[-0.03em]`}>
          The words arrive one line at a time
        </LineReveal>
        <p className="mt-10 font-mono text-[11px] tracking-wide uppercase opacity-60">Scroll</p>
      </section>

      <section className="px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} as="p" className={`max-w-[30ch] ${serif} text-[clamp(28px,4.4vw,64px)] leading-[1.08] tracking-[-0.015em]`}>
          A page that reads itself aloud in your own time, line after line, and never faster than you can follow.
        </LineReveal>
      </section>

      <section className="px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} highlight className={`max-w-[26ch] ${serif} text-[clamp(32px,6vw,96px)] leading-[1.02] tracking-[-0.02em]`}>
          Good typography is the quiet part of motion. Let every sentence be earned by the scroll, and let the reader decide the pace.
        </LineReveal>
      </section>

      <section className="border-t border-current/15 px-6 py-24 md:px-12 md:py-40">
        <LineReveal {...shared} as="h2" className={`mb-16 max-w-[16ch] ${serif} text-[clamp(40px,7vw,104px)] leading-[0.96] tracking-[-0.025em] md:mb-28`}>
          Three small promises
        </LineReveal>
        <div className="grid gap-16 md:grid-cols-3 md:gap-12">
          {notes.map((note) => (
            <article key={note.number} className="border-t border-current/20 pt-5">
              <p className="mb-6 font-mono text-[11px] tracking-wide uppercase opacity-60">{note.number}</p>
              <LineReveal {...shared} as="h2" className={`mb-5 ${serif} text-[clamp(28px,3vw,44px)] leading-[1.05]`}>
                {note.title}
              </LineReveal>
              <LineReveal {...shared} as="p" className="max-w-[38ch] text-[16px] leading-[1.55] opacity-80">
                {note.text}
              </LineReveal>
            </article>
          ))}
        </div>
      </section>

      <div className="h-24 md:h-40" />
    </div>
  );
}
```

## Agent prompt

```text
Add the Line Reveal component (<LineReveal />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/line-reveal
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/line-reveal.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/line-reveal.json

Then use it like this:
import { LineReveal } from "@/components/dimaac/line-reveal";
<LineReveal children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/line-reveal.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
