Reveals & Transitions

Curtain Reveal

One or two solid panels slide off the content like a stage curtain while the revealed content eases in behind them with a soft parallax drift.

Install

npx shadcn@latest add @paragon/curtain-reveal

curtain-reveal.tsx

"use client";

import * as React from "react";
import {
  motion,
  useInView,
  useReducedMotion,
  type Variants,
} from "motion/react";
import { cn } from "@/lib/utils";

export type CurtainDirection = "horizontal" | "vertical";
export type CurtainEasing = "out" | "in-out" | "drawer" | "spring";

/** House easing tokens, mirrored as bezier tuples for motion/react. */
type Bezier = [number, number, number, number];
const EASING_MAP: Record<CurtainEasing, Bezier> = {
  out: [0.22, 1, 0.36, 1],
  "in-out": [0.77, 0, 0.175, 1],
  drawer: [0.32, 0.72, 0, 1],
  spring: [0.2, 0, 0, 1],
};

export interface CurtainRevealProps extends React.ComponentProps<"div"> {
  /** Axis the curtain opens along. */
  direction?: CurtainDirection;
  /** true = two panels split apart from center; false = a single wipe. */
  split?: boolean;
  /** Named easing curve from the house tokens. */
  easing?: CurtainEasing;
  /** Reveal duration, in seconds. */
  duration?: number;
  /** Panel color. Defaults to the card token. */
  color?: string;
  /** How the reveal is triggered. */
  trigger?: "view" | "hover" | "click";
  /** Parallax offset applied to the revealed content, in px. 0 disables it. */
  parallax?: number;
  children: React.ReactNode;
}

/**
 * CurtainReveal — one or two solid panels slide off the content like a stage
 * curtain while the revealed content eases in behind them with a soft parallax
 * drift, so the uncovering feels layered rather than flat.
 *
 * Panels animate `transform` only (translate) — no layout thrash. Runs once on
 * scroll-into-view (`useInView`, once) or on hover/click. Under
 * `prefers-reduced-motion` it renders the final revealed state with the panels
 * already gone and no parallax. Content is always real DOM.
 */
export function CurtainReveal({
  direction = "horizontal",
  split = true,
  easing = "out",
  duration = 0.75,
  color = "var(--color-card)",
  trigger = "view",
  parallax = 16,
  className,
  children,
  ...props
}: CurtainRevealProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.35 });
  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 ease = EASING_MAP[easing];
  const dur = reduce ? 0 : duration;
  const isH = direction === "horizontal";

  // Off-screen translate that carries a panel fully out of frame.
  const away = "-101%";

  const panelTransition = { duration: dur, ease: [0.22, 1, 0.36, 1] as Bezier };

  const contentVariants: Variants = {
    hidden: {
      opacity: 0,
      x: !reduce && parallax ? (isH ? parallax : 0) : 0,
      y: !reduce && parallax ? (isH ? 0 : parallax) : 0,
    },
    shown: { opacity: 1, x: 0, y: 0 },
  };

  const panelStyle: React.CSSProperties = { background: color };

  return (
    <div
      ref={ref}
      data-slot="curtain-reveal"
      className={cn("relative overflow-hidden", className)}
      onMouseEnter={trigger === "hover" ? () => setHovered(true) : undefined}
      onClick={trigger === "click" ? () => setClicked(true) : undefined}
      {...props}
    >
      <motion.div
        initial={reduce ? "shown" : "hidden"}
        animate={open ? "shown" : "hidden"}
        variants={contentVariants}
        transition={{
          duration: dur * 1.15,
          ease,
          delay: reduce ? 0 : dur * 0.15,
        }}
        className="size-full"
      >
        {children}
      </motion.div>

      {!reduce && (
        <div aria-hidden className="pointer-events-none absolute inset-0">
          {split ? (
            <>
              <motion.div
                className={cn(
                  "absolute",
                  isH ? "inset-y-0 left-0 w-1/2" : "inset-x-0 top-0 h-1/2",
                )}
                style={panelStyle}
                initial={{ x: 0, y: 0 }}
                animate={
                  open
                    ? isH
                      ? { x: away }
                      : { y: away }
                    : { x: 0, y: 0 }
                }
                transition={panelTransition}
              />
              <motion.div
                className={cn(
                  "absolute",
                  isH ? "inset-y-0 right-0 w-1/2" : "inset-x-0 bottom-0 h-1/2",
                )}
                style={panelStyle}
                initial={{ x: 0, y: 0 }}
                animate={
                  open
                    ? isH
                      ? { x: "101%" }
                      : { y: "101%" }
                    : { x: 0, y: 0 }
                }
                transition={panelTransition}
              />
            </>
          ) : (
            <motion.div
              className="absolute inset-0"
              style={panelStyle}
              initial={{ x: 0, y: 0 }}
              animate={open ? (isH ? { x: away } : { y: away }) : { x: 0, y: 0 }}
              transition={panelTransition}
            />
          )}
        </div>
      )}
    </div>
  );
}