Line mask reveal
Lines of a heading slide up from behind a mask as they enter view, once.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/line-mask-reveal.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/line-mask-reveal
A two-tone heading that rises line by line. Reload to see it again.
Heading and paragraph
A heading, then a paragraph at a delay, each line on its own mask.
Usage
import { LineMaskReveal } from "@/components/ui/line-mask-reveal"
<LineMaskReveal as="h1" className="text-7xl leading-none font-medium tracking-tight">
Good interfaces are mostly the parts you never notice
</LineMaskReveal>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "h1" | "h2" | "h3" | "h4" | "p" | "div" | "h2" | The element to render. |
| duration | number | 1.1 | Seconds each line takes to rise. |
| delay | number | 0 | Seconds to wait after the heading enters view. |
Source
line-mask-reveal/line-mask-reveal.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, SplitText);
const MOTION = "(prefers-reduced-motion: no-preference)";
/** Room for descenders inside each line's mask, in em, so `leading-none` headings keep their tails. */
const BLEED = 0.16;
type LineMaskRevealProps = React.ComponentProps<"h2"> & {
/** The element to render. */
as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";
/** Seconds each line takes to rise. */
duration?: number;
/** Seconds to wait after the heading enters view. */
delay?: number;
};
/**
* Lines of a heading slide up from behind a mask the first time it enters view. The markup is the plain
* heading, so with no script or under reduced motion the text is simply there. The split only exists
* while the entrance plays: before that it re-splits on resize, after that the text is plain again.
*/
function LineMaskReveal({ as = "h2", duration = 1.1, delay = 0, className, children, ...props }: LineMaskRevealProps) {
const Tag = as as React.ElementType;
const root = React.useRef<HTMLElement>(null);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, (_, contextSafe) => {
const element = root.current;
if (!element || !contextSafe) return;
let alive = true;
let seen = false;
let entrance: gsap.core.Tween | undefined;
const watcher = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
watcher.disconnect();
seen = true;
entrance?.play();
},
{ threshold: 0.1 },
);
const start = contextSafe(() => {
if (!alive) return;
SplitText.create(element, {
type: "lines",
mask: "lines",
tag: "span",
aria: "none",
autoSplit: true,
onSplit(self: SplitText) {
// Span wrappers stay valid inside any tag, but they are inline, and inline boxes ignore transforms.
gsap.set(self.lines, { display: "block" });
gsap.set(self.masks, { display: "block", paddingBottom: `${BLEED}em`, marginBottom: `${-BLEED}em` });
entrance = gsap.from(self.lines, {
yPercent: 100 + BLEED * 125,
duration,
delay,
ease: "power3.out",
stagger: Math.min(0.12, 0.6 / self.lines.length),
paused: true,
onComplete: () => void self.revert(),
});
if (seen) entrance.play();
return entrance;
},
});
watcher.observe(element);
}) as () => void;
// Lines are measured, so the split waits for the real font. Until then the text stays as it is.
void document.fonts.ready.then(start);
return () => {
alive = false;
watcher.disconnect();
};
});
return () => media.revert();
},
{ scope: root, dependencies: [duration, delay] },
);
return (
<Tag ref={root} data-slot="line-mask-reveal" className={cn("m-0", className)} {...props}>
{children}
</Tag>
);
}
export { LineMaskReveal };
For AI agents
Hand your agent this page, the markdown at /components/line-mask-reveal/prompt.md, or the Copy AI prompt button above.
When to use
- The one heading a section is built around
- A hero, where the text must exist before any script runs
- Short paragraphs that follow a heading, at a small delay
When not to use
- Every heading on a page: one entrance per section
- Body copy longer than a few lines
- Text people return to often, such as app chrome
- On a hero above the fold, where the text paints first and the entrance reads as a flash
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- The text is plain markup first, so it paints before hydration and stays if the script fails. It then hides for the entrance, which waits for document.fonts.ready because lines are measured
- The split only lives while the entrance plays. It re-splits on resize before and during the entrance, then reverts to plain text, so later resizes need nothing
- Inner elements such as a muted span survive the split. Under reduced motion it never splits
The maths
- Stagger is min(0.12, 0.6 / n) seconds, so two lines step 0.12 and ten lines still start within 0.6.
- Each mask bleeds 0.16em and a line starts 120 percent down, so descenders stay whole at leading-none and nothing peeks before the rise.