The classic editorial reveal: each line sits inside an overflow mask and slides up from its own baseline with a per-line stagger.
npx shadcn@latest add @paragon/mask-line-reveal"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type MaskLineDirection = "up" | "down";
export interface MaskLineRevealProps
extends Omit<React.ComponentProps<"div">, "children"> {
/**
* Lines to reveal. Pass an array of strings, or nodes — each item becomes one
* masked line. If a single string is passed, it is split on newlines.
*/
lines: React.ReactNode[] | string;
/** Per-line stagger, in seconds. */
stagger?: number;
/** Slide direction the line enters from. */
direction?: MaskLineDirection;
/** Per-line slide duration, in seconds. */
duration?: number;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
/** Delay before the first line starts, in seconds — for sequencing blocks. */
delay?: number;
/** Element/class applied to each line (e.g. a heading style). */
lineClassName?: string;
}
/**
* MaskLineReveal — the classic editorial reveal: each line sits inside an
* `overflow-hidden` mask and slides in from just off its own baseline with a
* per-line stagger, so a headline or paragraph unfurls line by line. A `delay`
* prop sequences multiple blocks (headline first, body after).
*
* Only `transform` (translateY) + `opacity` animate. The real text stays in the
* DOM and readable to assistive tech (the mask is presentational). Triggers:
* `view` runs once on scroll-into-view; `hover` arms once on first hover (text
* that un-reveals on leave would yank copy away mid-read), falling back to
* `view` on touch devices; `click` is keyboard-operable (Enter/Space). Under
* `prefers-reduced-motion` every line is shown at rest with no slide.
*/
export function MaskLineReveal({
lines,
stagger = 0.08,
direction = "up",
duration = 0.65,
trigger = "view",
delay = 0,
lineClassName,
className,
onClick,
onKeyDown,
onPointerEnter,
...props
}: MaskLineRevealProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const reduce = useReducedMotion();
const [fine, setFine] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
const open =
reduce ||
(effectiveTrigger === "view" && inView) ||
(effectiveTrigger === "hover" && hovered) ||
(effectiveTrigger === "click" && clicked);
const items = React.useMemo<React.ReactNode[]>(
() => (typeof lines === "string" ? lines.split("\n") : lines),
[lines],
);
const from = direction === "up" ? "110%" : "-110%";
const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;
return (
<div
ref={ref}
data-slot="mask-line-reveal"
className={cn(className)}
role={awaitingClick ? "button" : undefined}
tabIndex={awaitingClick ? 0 : undefined}
aria-label={awaitingClick ? "Reveal text" : undefined}
onPointerEnter={(e) => {
onPointerEnter?.(e);
if (effectiveTrigger === "hover") setHovered(true);
}}
onClick={(e) => {
onClick?.(e);
if (effectiveTrigger === "click") setClicked(true);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (awaitingClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setClicked(true);
}
}}
{...props}
>
{items.map((line, i) => (
<span
key={i}
className="block overflow-hidden"
// A hair of vertical padding keeps descenders (g, y, p) from clipping.
style={{ paddingBottom: "0.08em" }}
>
<motion.span
className={cn("block will-change-transform", lineClassName)}
initial={{ y: reduce ? "0%" : from, opacity: reduce ? 1 : 0 }}
animate={{
y: open ? "0%" : from,
opacity: open ? 1 : 0,
}}
transition={{
duration: reduce ? 0 : duration,
delay: reduce ? 0 : delay + i * stagger,
ease: [0.22, 1, 0.36, 1],
}}
>
{line}
</motion.span>
</span>
))}
</div>
);
}