Each line of the headline rises from behind its own edge, one after another. No fade, no blur: the text is crisp the whole way and simply arrives.
"use client";import { useRef, type ComponentPropsWithoutRef } from "react";import gsap from "gsap";import { SplitText } from "gsap/SplitText";import { useGSAP } from "@gsap/react";gsap.registerPlugin(SplitText, useGSAP);type LineMaskHeadlineProps = ComponentPropsWithoutRef<"h2"> & { duration?: number; stagger?: number; ease?: string;};export function LineMaskHeadline({ duration = 0.9, stagger = 0.08, ease = "expo.out", children, ...props}: LineMaskHeadlineProps) { const ref = useRef<HTMLHeadingElement>(null); useGSAP( () => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // autoSplit re-splits when fonts load or the width changes; returning the tween lets it carry progress over SplitText.create(ref.current, { type: "lines", mask: "lines", autoSplit: true, onSplit: (self) => gsap.from(self.lines, { yPercent: 105, duration, ease, stagger }), }); }, { scope: ref, dependencies: [duration, stagger, ease], revertOnUpdate: true }, ); return ( <h2 ref={ref} {...props}> {children} </h2> );}expo.out puts almost all the travel in the first third. Each line arrives decisively, then spends the rest of its time settling.
0.08s between lines reads as a sequence, top to bottom, while the whole headline still lands in about a second.
Lines rise from behind a clip instead of fading in, so the type stays sharp. autoSplit re-splits on resize and font load and carries the progress over.
SplitText labels the heading with its full text and hides the split pieces, so screen readers read it once, as written. With reduced motion on, the headline is static. It plays once on mount and never loops.