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.
npx shadcn@latest add @paragon/curtain-reveal"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],
};
const EXIT_EASE: Bezier = [0.4, 0, 1, 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. Split panels overlap
* the centerline by half a pixel, so no hairline seam ever shows through the
* closed curtain, and each panel carries a whisper of shading along its parting
* edge for physicality.
*
* Panels animate `transform` only (translate) — no layout thrash — and honor
* the `easing` prop; closing runs at roughly half the opening duration with the
* exit curve, per the house asymmetric enter/exit rule. Triggers: `view` runs
* once on scroll-into-view; `hover` opens on hover and closes on leave
* (interruptible), falling back to `view` on touch; `click` is
* keyboard-operable (Enter/Space). Under `prefers-reduced-motion` it renders
* the final revealed state with the panels already gone and no parallax.
*/
export function CurtainReveal({
direction = "horizontal",
split = true,
easing = "out",
duration = 0.75,
color = "var(--color-card)",
trigger = "view",
parallax = 16,
className,
children,
onClick,
onKeyDown,
onPointerEnter,
onPointerLeave,
...props
}: CurtainRevealProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.35 });
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 ease = EASING_MAP[easing];
const dur = reduce ? 0 : duration;
const isH = direction === "horizontal";
const contentVariants: Variants = {
hidden: {
opacity: 0,
x: !reduce && parallax ? (isH ? parallax : 0) : 0,
y: !reduce && parallax ? (isH ? 0 : parallax) : 0,
transition: { duration: dur * 0.4, ease: EXIT_EASE },
},
shown: {
opacity: 1,
x: 0,
y: 0,
transition: { duration: dur * 1.15, ease, delay: reduce ? 0 : dur * 0.15 },
},
};
// Panels: open with the chosen curve, close in roughly half the time with
// the exit curve — closing never retraces the opening.
const panelVariants = (away: { x?: string; y?: string }): Variants => ({
closed: {
x: "0%",
y: "0%",
transition: { duration: dur * 0.55, ease: EXIT_EASE },
},
open: { ...away, transition: { duration: dur, ease } },
});
// Shading along the parting edge gives the flat panel physical presence.
const edgeShade = (edge: "right" | "left" | "bottom" | "top") =>
`linear-gradient(to ${edge}, transparent 82%, color-mix(in oklch, var(--color-foreground) 7%, transparent))`;
const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;
return (
<div
ref={ref}
data-slot="curtain-reveal"
className={cn("relative overflow-hidden", className)}
role={awaitingClick ? "button" : undefined}
tabIndex={awaitingClick ? 0 : undefined}
aria-label={awaitingClick ? "Reveal content" : undefined}
onPointerEnter={(e) => {
onPointerEnter?.(e);
if (effectiveTrigger === "hover") setHovered(true);
}}
onPointerLeave={(e) => {
onPointerLeave?.(e);
if (effectiveTrigger === "hover") setHovered(false);
}}
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}
>
<motion.div
initial={reduce ? "shown" : "hidden"}
animate={open ? "shown" : "hidden"}
variants={contentVariants}
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" : "inset-x-0 top-0")}
style={{
background: color,
backgroundImage: edgeShade(isH ? "right" : "bottom"),
// Overlap the centerline by 0.5px so the closed curtain
// never shows a sub-pixel seam.
...(isH
? { width: "calc(50% + 0.5px)" }
: { height: "calc(50% + 0.5px)" }),
}}
initial="closed"
animate={open ? "open" : "closed"}
variants={panelVariants(isH ? { x: "-102%" } : { y: "-102%" })}
/>
<motion.div
className={cn("absolute", isH ? "inset-y-0 right-0" : "inset-x-0 bottom-0")}
style={{
background: color,
backgroundImage: edgeShade(isH ? "left" : "top"),
...(isH
? { width: "calc(50% + 0.5px)" }
: { height: "calc(50% + 0.5px)" }),
}}
initial="closed"
animate={open ? "open" : "closed"}
variants={panelVariants(isH ? { x: "102%" } : { y: "102%" })}
/>
</>
) : (
<motion.div
className="absolute inset-0"
style={{
background: color,
backgroundImage: edgeShade(isH ? "right" : "bottom"),
}}
initial="closed"
animate={open ? "open" : "closed"}
variants={panelVariants(isH ? { x: "-101%" } : { y: "-101%" })}
/>
)}
</div>
)}
</div>
);
}