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";
/** 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.
*
* Only `transform` (translateY) + `opacity` animate. The real text stays in the
* DOM and readable to assistive tech (the mask is presentational). Runs once on
* scroll-into-view (`useInView`, once) or on hover/click; 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",
lineClassName,
className,
...props
}: MaskLineRevealProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const reduce = useReducedMotion();
const [hovered, setHovered] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
const open =
reduce ||
(trigger === "view" && inView) ||
(trigger === "hover" && hovered) ||
(trigger === "click" && clicked);
const items = React.useMemo<React.ReactNode[]>(
() => (typeof lines === "string" ? lines.split("\n") : lines),
[lines],
);
const from = direction === "up" ? "110%" : "-110%";
return (
<div
ref={ref}
data-slot="mask-line-reveal"
className={cn(className)}
onMouseEnter={trigger === "hover" ? () => setHovered(true) : undefined}
onClick={trigger === "click" ? () => setClicked(true) : undefined}
{...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 : i * stagger,
ease: [0.22, 1, 0.36, 1],
}}
>
{line}
</motion.span>
</span>
))}
</div>
);
}