Reveals & Transitions

Shutter Reveal

A stack of venetian-blind slats flips open in 3D sequence, sweeping across the surface to uncover the content behind them.

Install

npx shadcn@latest add @paragon/shutter-reveal

shutter-reveal.tsx

"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. Runs once on scroll-into-view
 * (`useInView`, once) or on hover/click. 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,
  ...props
}: ShutterRevealProps) {
  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 count = Math.max(1, Math.round(slats));
  const isH = direction === "horizontal";
  const slatList = React.useMemo(
    () => Array.from({ length: count }, (_, i) => i),
    [count],
  );

  return (
    <div
      ref={ref}
      data-slot="shutter-reveal"
      className={cn("relative overflow-hidden", className)}
      onMouseEnter={trigger === "hover" ? () => setHovered(true) : undefined}
      onClick={trigger === "click" ? () => setClicked(true) : undefined}
      {...props}
    >
      {children}

      {!reduce && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 flex"
          style={{
            flexDirection: isH ? "column" : "row",
            perspective: "1200px",
          }}
        >
          {slatList.map((i) => (
            <motion.div
              key={i}
              className="relative flex-1"
              style={{
                background: color,
                transformOrigin: isH ? "center top" : "left center",
                transformStyle: "preserve-3d",
                backfaceVisibility: "hidden",
                // A faint edge shadow gives the slats physical thickness.
                boxShadow: isH
                  ? "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)"
                  : "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 } : { rotateY: 0, opacity: 1 }
              }
              animate={
                open
                  ? isH
                    ? { rotateX: -92, opacity: 0 }
                    : { rotateY: 92, opacity: 0 }
                  : isH
                    ? { rotateX: 0, opacity: 1 }
                    : { rotateY: 0, opacity: 1 }
              }
              transition={{
                duration: reduce ? 0 : duration,
                delay: reduce ? 0 : i * stagger,
                ease: [0.22, 1, 0.36, 1],
                opacity: {
                  duration: reduce ? 0 : duration * 0.6,
                  delay: reduce ? 0 : i * stagger + duration * 0.4,
                },
              }}
            />
          ))}
        </div>
      )}
    </div>
  );
}