A stack of venetian-blind slats flips open in 3D sequence, sweeping across the surface to uncover the content behind them.
npx shadcn@latest add @paragon/shutter-reveal"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type ShutterDirection = "horizontal" | "vertical";
export interface ShutterRevealProps extends React.ComponentProps<"div"> {
/** Number of blind slats. */
slats?: number;
/** horizontal = slats stack top→bottom and flip on X; vertical = left→right on Y. */
direction?: ShutterDirection;
/** Per-slat delay, in seconds. */
stagger?: number;
/** Time each slat takes to flip open, in seconds. */
duration?: number;
/** Slat color. Defaults to the card token. */
color?: string;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
children: React.ReactNode;
}
/**
* ShutterReveal — a stack of venetian-blind slats flips open in sequence to
* uncover the content behind them. Each slat rotates in 3D (preserve-3d) from
* closed to edge-on with a per-slat stagger, so the reveal sweeps across the
* surface rather than snapping.
*
* Slats animate `transform` + `opacity` only (a zero-bounce spring drives the
* flip), and each slat bleeds ~0.6px in its own color so composited-layer
* rounding never shows a hairline of content through the closed shutter; the
* opacity only fades once a slat is nearly edge-on, so no content peeks at the
* hinge. Runs once on scroll-into-view (`useInView`, once) or on hover/click
* (`hover` falls back to `view` on touch where hover never fires; `click` is
* keyboard-operable — Enter/Space). Under `prefers-reduced-motion` the slats
* are simply absent and the content shows immediately. Content is real,
* accessible DOM beneath a `pointer-events-none`, `aria-hidden` slat layer.
*/
export function ShutterReveal({
slats = 8,
direction = "horizontal",
stagger = 0.06,
duration = 0.5,
color = "var(--color-card)",
trigger = "view",
className,
children,
onClick,
onKeyDown,
onPointerEnter,
...props
}: ShutterRevealProps) {
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);
}, []);
// Hover can never fire on touch — fall back to the in-view reveal there.
const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
const open =
reduce ||
(effectiveTrigger === "view" && inView) ||
(effectiveTrigger === "hover" && hovered) ||
(effectiveTrigger === "click" && clicked);
const count = Math.max(1, Math.round(slats));
const isH = direction === "horizontal";
const slatList = React.useMemo(
() => Array.from({ length: count }, (_, i) => i),
[count],
);
// A slat's front face carries a light-to-shadow gradient across its width so
// the venetian blind catches "light" — top/left edge bright, far edge dim.
const faceShade = isH
? `linear-gradient(to bottom, color-mix(in oklch, #fff 10%, transparent), transparent 42%, color-mix(in oklch, #000 14%, transparent))`
: `linear-gradient(to right, color-mix(in oklch, #fff 10%, transparent), transparent 42%, color-mix(in oklch, #000 14%, transparent))`;
const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;
return (
<div
ref={ref}
data-slot="shutter-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);
}}
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}
>
{children}
{!reduce && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 flex"
style={{
flexDirection: isH ? "column" : "row",
perspective: "1200px",
perspectiveOrigin: "center",
}}
>
{slatList.map((i) => (
<motion.div
key={i}
className="relative flex-1"
style={{
background: color,
backgroundImage: faceShade,
// Hinge on the leading edge and pull the plane a hair toward the
// viewer (translateZ) so the whole shutter reads as one surface
// sitting just above the content, not co-planar slices.
transformOrigin: isH ? "center top" : "left center",
transformStyle: "preserve-3d",
backfaceVisibility: "hidden",
// The 0.6px spread bleeds each slat over the inter-slat
// rounding seam (slats are separate composited layers), and a
// faint inset edge shadow gives them physical thickness.
boxShadow: isH
? `0 0 0 0.6px ${color}, inset 0 1px 0 color-mix(in oklch, var(--color-foreground) 8%, transparent), inset 0 -1px 0 color-mix(in oklch, var(--color-background) 40%, transparent)`
: `0 0 0 0.6px ${color}, inset 1px 0 0 color-mix(in oklch, var(--color-foreground) 8%, transparent), inset -1px 0 0 color-mix(in oklch, var(--color-background) 40%, transparent)`,
}}
initial={
isH
? { rotateX: 0, opacity: 1, z: 0.5 }
: { rotateY: 0, opacity: 1, z: 0.5 }
}
animate={
open
? isH
? { rotateX: -90, opacity: 0, z: 0.5 }
: { rotateY: 90, opacity: 0, z: 0.5 }
: isH
? { rotateX: 0, opacity: 1, z: 0.5 }
: { rotateY: 0, opacity: 1, z: 0.5 }
}
transition={{
// Springy, zero-bounce rotation reads more physical than a
// fixed tween; the opacity only starts once the slat is nearly
// edge-on so no hairline of content peeks at the hinge.
rotateX: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : i * stagger },
rotateY: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : i * stagger },
opacity: {
duration: reduce ? 0 : duration * 0.4,
delay: reduce ? 0 : i * stagger + duration * 0.55,
ease: [0.4, 0, 1, 1],
},
}}
/>
))}
</div>
)}
</div>
);
}