Blinds Reveal
Reveals & Transitions

Blinds Reveal

Venetian louvre slats tilt open in 3D sequence — thinning as they rotate edge-on — to uncover the content, sweeping from an edge or opening from the center.

Install

npx shadcn@latest add @paragon/blinds-reveal

blinds-reveal.tsx

"use client";

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

export type BlindsDirection = "horizontal" | "vertical";

export interface BlindsRevealProps extends React.ComponentProps<"div"> {
  /** Number of louvre slats. */
  slats?: number;
  /** horizontal = slats stack top→bottom and tilt on X; vertical = left→right on Y. */
  direction?: BlindsDirection;
  /** Per-slat stagger, in seconds. */
  stagger?: number;
  /** Time each slat takes to tilt open, in seconds. */
  duration?: number;
  /** Slat color. Defaults to the card token. */
  color?: string;
  /** true = sweep from the near edge; false = open from the center outward. */
  fromEdge?: boolean;
  /** How the reveal is triggered. */
  trigger?: "view" | "hover" | "click";
  children: React.ReactNode;
}

/**
 * BlindsReveal — a stack of venetian louvre slats tilts open in sequence to
 * uncover the content behind them. Unlike a hard shutter, each slat keeps its
 * body: it rotates toward edge-on while its cross-axis thickness eases to zero
 * and a light-to-shadow gradient rolls across its face, so the louvres read as
 * real blinds catching light as the gaps between them widen. `direction` sets
 * slat orientation (horizontal or vertical) and `fromEdge` chooses a straight
 * sweep or a symmetric open-from-the-center.
 *
 * Slats animate `transform` (rotate + scale) + `opacity` only, driven by a
 * zero-bounce spring, and each slat bleeds ~0.6px in its own color so
 * composited-layer rounding never shows a hairline of content through the
 * closed blind. 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 BlindsReveal({
  slats = 9,
  direction = "horizontal",
  stagger = 0.055,
  duration = 0.6,
  color = "var(--color-card)",
  fromEdge = true,
  trigger = "view",
  className,
  children,
  onClick,
  onKeyDown,
  onPointerEnter,
  ...props
}: BlindsRevealProps) {
  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 count = Math.max(1, Math.round(slats));
  const isH = direction === "horizontal";
  const slatList = React.useMemo(
    () => Array.from({ length: count }, (_, i) => i),
    [count],
  );

  // Center distance (0 at the middle slat, 1 at the ends) → symmetric open.
  const mid = (count - 1) / 2;
  const delayFor = (i: number) =>
    fromEdge ? i * stagger : (mid > 0 ? Math.abs(i - mid) / mid : 0) * mid * stagger;

  // A louvre's face carries a light-to-shadow gradient across its short axis so
  // it catches "light" — the bright edge migrates as the slat tilts.
  const faceShade = isH
    ? `linear-gradient(to bottom, color-mix(in oklch, #fff 12%, transparent), transparent 40%, color-mix(in oklch, #000 16%, transparent))`
    : `linear-gradient(to right, color-mix(in oklch, #fff 12%, transparent), transparent 40%, color-mix(in oklch, #000 16%, transparent))`;

  const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;

  return (
    <div
      ref={ref}
      data-slot="blinds-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: "1100px",
            perspectiveOrigin: "center",
          }}
        >
          {slatList.map((i) => (
            <motion.div
              key={i}
              className="relative flex-1 origin-center"
              style={{
                background: color,
                backgroundImage: faceShade,
                transformStyle: "preserve-3d",
                backfaceVisibility: "hidden",
                // The 0.6px spread bleeds each slat over the inter-slat rounding
                // seam; a faint inset edge gives the louvre 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)`,
              }}
              // Closed: flat, facing the viewer, full thickness. Open: tilted
              // ~78° edge-on with the visible louvre thinned toward zero, so the
              // gaps widen like a real blind rather than a slat vanishing.
              initial={
                isH
                  ? { rotateX: 0, scaleY: 1, opacity: 1 }
                  : { rotateY: 0, scaleX: 1, opacity: 1 }
              }
              animate={
                open
                  ? isH
                    ? { rotateX: -78, scaleY: 0.06, opacity: 0 }
                    : { rotateY: 78, scaleX: 0.06, opacity: 0 }
                  : isH
                    ? { rotateX: 0, scaleY: 1, opacity: 1 }
                    : { rotateY: 0, scaleX: 1, opacity: 1 }
              }
              transition={{
                rotateX: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : delayFor(i) },
                rotateY: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : delayFor(i) },
                scaleX: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : delayFor(i) },
                scaleY: { type: "spring", duration, bounce: 0, delay: reduce ? 0 : delayFor(i) },
                opacity: {
                  duration: reduce ? 0 : duration * 0.45,
                  delay: reduce ? 0 : delayFor(i) + duration * 0.5,
                  ease: [0.4, 0, 1, 1],
                },
              }}
            />
          ))}
        </div>
      )}
    </div>
  );
}