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