Mask Line Reveal
Reveals & Transitions

Mask Line Reveal

The classic editorial reveal: each line sits inside an overflow mask and slides up from its own baseline with a per-line stagger.

Install

npx shadcn@latest add @paragon/mask-line-reveal

mask-line-reveal.tsx

"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>
  );
}