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],
};
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>
);
}