{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "door-reveal",
  "type": "registry:ui",
  "title": "Door Reveal",
  "description": "Doors that swing open in CSS 3D around their hinges, with shading and a slight overshoot, to reveal the content behind.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/door-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\";\n\nexport type DoorRevealVariant = \"double\" | \"single\" | \"shutters\";\nexport interface DoorRevealSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface DoorRevealProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** What waits behind the doors. Inert (unfocusable, hidden from assistive tech) until they open. */\n  children: React.ReactNode;\n  /** Two doors meeting in the middle, one door hinged on the left, or louvred shutters. */\n  variant?: DoorRevealVariant;\n  /** Open on click / Enter only, or also automatically once the doors scroll into view. */\n  trigger?: \"click\" | \"view\";\n  /** Controlled open state. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** How far the doors swing, in degrees (shutters stop at 90°). */\n  angle?: number;\n  /** Spring for opening; closing uses the same stiffness, critically damped so doors never pass the frame. */\n  spring?: DoorRevealSpring;\n  /** CSS perspective in px; smaller is more dramatic. */\n  perspective?: number;\n  /** Strength of the shading as the panels turn away from the light, 0–1. */\n  shading?: number;\n  doorColor?: string;\n  /** Handles and focus ring. */\n  accent?: string;\n  /** Size of the opening in px (it scales down to fit narrow containers). */\n  width?: number;\n  height?: number;\n  /** Number of louvres in the shutters variant. */\n  slats?: number;\n  openLabel?: string;\n  closeLabel?: string;\n}\n\nconst THICKNESS = 8;\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\n/** Raised-and-fielded panels, drawn with borders and inset shadows. */\nfunction Panels({ rows }: { rows: number[] }) {\n  return (\n    <div className=\"absolute inset-[9%_13%] flex flex-col gap-[7%]\">\n      {rows.map((grow, i) => (\n        <div\n          key={i}\n          className=\"rounded-[3px] border border-white/[0.07] shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_-1px_0_rgb(0_0_0/0.35),0_1px_0_rgb(255_255_255/0.05),0_-1px_0_rgb(0_0_0/0.3)]\"\n          style={{ flexGrow: grow }}\n        >\n          <div className=\"m-[9%] h-[calc(100%-18%)] rounded-[2px] bg-[linear-gradient(160deg,rgb(255_255_255/0.05),rgb(0_0_0/0.12))] shadow-[0_0_0_1px_rgb(0_0_0/0.18)]\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n\ninterface LeafProps {\n  progress: MotionValue<number>;\n  /** Which edge the hinge is on. */\n  hinge: \"left\" | \"right\";\n  angle: number;\n  shading: number;\n  className: string;\n  /** Corner rounding for the faces (matches the opening's corners). */\n  rounded: string;\n  handle: boolean;\n}\n\n/** One door leaf: front, back and the free edge's thickness, hinged on one side. */\nfunction Leaf({ progress, hinge, angle, shading, className, rounded, handle }: LeafProps) {\n  const sign = hinge === \"left\" ? -1 : 1;\n  const rotateY = useTransform(progress, (p) => sign * angle * p);\n  const frontShade = useTransform(progress, (p) => clamp01(p * 1.1) * shading * 0.75);\n  const backShade = useTransform(progress, (p) => (1 - clamp01(p)) * shading * 0.6);\n  const free = hinge === \"left\" ? \"right\" : \"left\";\n\n  return (\n    <motion.div\n      className={`absolute top-0 h-full [transform-style:preserve-3d] ${className}`}\n      style={{ rotateY, transformOrigin: `${hinge} center` }}\n    >\n      {/* Front (outside) */}\n      <div\n        className={`absolute inset-0 overflow-hidden bg-[var(--dr-door)] [backface-visibility:hidden] ${rounded}`}\n        style={{ transform: `translateZ(${THICKNESS}px)` }}\n      >\n        <div\n          className=\"absolute inset-0\"\n          style={{\n            background: `linear-gradient(${hinge === \"left\" ? 90 : 270}deg, rgb(0 0 0 / 0.18), rgb(255 255 255 / 0.05) 55%, rgb(0 0 0 / 0.1))`,\n          }}\n        />\n        <Panels rows={[1.55, 1]} />\n        {/* Hinges */}\n        {[0.12, 0.84].map((top) => (\n          <span\n            key={top}\n            className=\"absolute h-[7%] w-[3px] rounded-[1px] bg-[linear-gradient(90deg,#8a8f98,#d4d4d8,#71717a)]\"\n            style={{ top: `${top * 100}%`, [hinge]: 0 }}\n          />\n        ))}\n        {handle ? (\n          <span\n            className=\"absolute top-[47%] flex h-[16%] w-[7px] flex-col items-center justify-between\"\n            style={{ [free]: \"9%\" }}\n          >\n            <span className=\"size-[11px] shrink-0 rounded-full bg-[var(--dr-accent)] shadow-[inset_0_-2px_3px_rgb(0_0_0/0.35),0_2px_4px_rgb(0_0_0/0.4)]\" />\n            <span className=\"w-[5px] flex-1 rounded-full bg-[var(--dr-accent)] shadow-[inset_-1px_0_2px_rgb(0_0_0/0.35),0_3px_6px_rgb(0_0_0/0.35)]\" />\n            <span className=\"mt-[2px] size-[4px] shrink-0 rounded-full bg-black/60\" />\n          </span>\n        ) : null}\n        <motion.div className=\"pointer-events-none absolute inset-0 bg-black\" style={{ opacity: frontShade }} />\n      </div>\n      {/* Back (inside), seen once the door swings past 90° */}\n      <div className={`absolute inset-0 overflow-hidden bg-[color-mix(in_srgb,var(--dr-door)_86%,#fff)] [backface-visibility:hidden] [transform:rotateY(180deg)] ${rounded}`}>\n        <Panels rows={[1.55, 1]} />\n        <motion.div className=\"pointer-events-none absolute inset-0 bg-black\" style={{ opacity: backShade }} />\n      </div>\n      {/* Free edge: the door's thickness */}\n      <div\n        className=\"absolute top-0 h-full bg-[color-mix(in_srgb,var(--dr-door)_70%,#000)]\"\n        style={{ width: THICKNESS, [free]: 0, transformOrigin: `${free} center`, transform: `rotateY(${hinge === \"left\" ? 90 : -90}deg)` }}\n      />\n    </motion.div>\n  );\n}\n\n/** One louvre of the shutters variant, turning around its own centre after a small stagger. */\nfunction Slat({ progress, index, total, shading }: { progress: MotionValue<number>; index: number; total: number; shading: number }) {\n  const stagger = Math.min(0.08, 0.5 / total);\n  const span = 1 - stagger * (total - 1);\n  const local = (p: number) => (p - stagger * index) / span;\n  // Louvres stop edge-on at 90° (no overshoot past it, which would show their backs).\n  const rotateY = useTransform(progress, (p) => 90 * clamp01(local(p)));\n  const shade = useTransform(progress, (p) => clamp01(local(p)) * shading * 0.8);\n  return (\n    <motion.div\n      className=\"absolute top-0 h-full [transform-style:preserve-3d]\"\n      style={{ left: `${(index / total) * 100}%`, width: `${100 / total}%`, rotateY }}\n    >\n      <div className=\"absolute inset-0 border-x border-black/25 bg-[var(--dr-door)] [backface-visibility:hidden]\">\n        <div className=\"absolute inset-0 bg-[repeating-linear-gradient(180deg,rgb(255_255_255/0.06)_0_2px,transparent_2px_14px,rgb(0_0_0/0.22)_14px_16px)]\" />\n        <motion.div className=\"absolute inset-0 bg-black\" style={{ opacity: shade }} />\n      </div>\n      <div className=\"absolute inset-0 bg-[color-mix(in_srgb,var(--dr-door)_80%,#fff)] [backface-visibility:hidden] [transform:rotateY(180deg)]\" />\n    </motion.div>\n  );\n}\n\n/**\n * Doors that swing open in CSS 3D to reveal what's behind them: a double door, a single door, or\n * louvred shutters. Each leaf turns around its hinge on a spring with a slight overshoot, shading as it\n * turns from the light, with panels, hinges, handles and edge thickness drawn in CSS. A real button\n * (aria-expanded) opens and closes them, as do clicks on the doors; they can also open on view. The\n * content behind stays inert until the doors open. With reduced motion the doors crossfade instead.\n */\nexport function DoorReveal({\n  children,\n  variant = \"double\",\n  trigger = \"click\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  angle = 105,\n  spring = { stiffness: 40, damping: 9, mass: 1 },\n  perspective = 1400,\n  shading = 0.6,\n  doorColor = \"#23282F\",\n  accent = \"#FF7448\",\n  width = 300,\n  height = 380,\n  slats = 6,\n  openLabel = \"Open the doors\",\n  closeLabel = \"Close the doors\",\n  className = \"\",\n  style,\n  ...rest\n}: DoorRevealProps) {\n  const reduced = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState(defaultOpen);\n  const open = openProp ?? internal;\n  const contentId = `${React.useId()}-content`;\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const progress = useMotionValue(open ? 1 : 0);\n  const fade = useMotionValue(open ? 0 : 1);\n  const innerShade = useTransform(progress, (p) => 1 - clamp01(p) * 0.85);\n\n  const setOpen = (next: boolean) => {\n    if (next === open) return;\n    if (openProp === undefined) setInternal(next);\n    onOpenChange?.(next);\n  };\n  const setOpenRef = React.useRef(setOpen);\n  React.useEffect(() => {\n    setOpenRef.current = setOpen;\n  });\n\n  // Swing (or, with reduced motion, crossfade) whenever the open state changes.\n  React.useEffect(() => {\n    if (reduced) {\n      progress.jump(0);\n      const fading = animate(fade, open ? 0 : 1, { duration: 0.35, ease: \"easeInOut\" });\n      return () => fading.stop();\n    }\n    fade.jump(1);\n    // Opening keeps the spring's overshoot; closing is critically damped so leaves never pass the frame.\n    const critical = 2 * Math.sqrt(spring.stiffness * spring.mass);\n    const swing = animate(progress, open ? 1 : 0, {\n      type: \"spring\",\n      stiffness: spring.stiffness,\n      mass: spring.mass,\n      damping: open ? spring.damping : Math.max(spring.damping, critical),\n      restDelta: 0.001,\n    });\n    return () => swing.stop();\n  }, [open, reduced, spring.stiffness, spring.damping, spring.mass, progress, fade]);\n\n  // trigger=\"view\": open once, when most of the doorway is on screen.\n  React.useEffect(() => {\n    if (trigger !== \"view\") return;\n    const el = frameRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        setOpenRef.current(true);\n        io.disconnect();\n      },\n      { threshold: 0.6 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [trigger]);\n\n  const shutterCount = Math.max(2, Math.min(12, Math.round(slats)));\n\n  return (\n    <div\n      {...rest}\n      className={`flex w-full flex-col items-center gap-5 ${className}`}\n      style={{ ...style, [\"--dr-door\" as string]: doorColor, [\"--dr-accent\" as string]: accent }}\n    >\n      {/* Frame (architrave) */}\n      <div\n        ref={frameRef}\n        className=\"relative rounded-[14px] bg-[linear-gradient(180deg,#e7e5e0,#d6d3cc)] p-[10px] shadow-[0_1px_0_rgb(255_255_255/0.6)_inset,0_24px_48px_-28px_rgb(0_0_0/0.45)] dark:bg-[linear-gradient(180deg,#2c3138,#22262c)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)_inset,0_24px_48px_-28px_rgb(0_0_0/0.8)]\"\n        style={{ width, maxWidth: \"100%\" }}\n      >\n        <div\n          className=\"relative w-full rounded-[6px] bg-zinc-100 dark:bg-zinc-900\"\n          style={{ aspectRatio: `${width} / ${height}`, perspective }}\n        >\n          <div id={contentId} inert={!open} className=\"absolute inset-0 overflow-hidden rounded-[6px]\">\n            {children}\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 shadow-[inset_0_0_36px_rgb(0_0_0/0.55)]\"\n              style={{ opacity: innerShade }}\n            />\n          </div>\n\n          {/* The doors: visual only; the button below is the accessible control. */}\n          <motion.div\n            aria-hidden=\"true\"\n            onClick={() => setOpen(true)}\n            className={`absolute inset-0 [transform-style:preserve-3d] ${open ? \"pointer-events-none\" : \"cursor-pointer\"}`}\n            style={{ opacity: fade }}\n          >\n            {variant === \"double\" ? (\n              <>\n                <Leaf progress={progress} hinge=\"left\" angle={angle} shading={shading} handle className=\"left-0 w-1/2\" rounded=\"rounded-l-[6px]\" />\n                <Leaf progress={progress} hinge=\"right\" angle={angle} shading={shading} handle className=\"right-0 w-1/2\" rounded=\"rounded-r-[6px]\" />\n              </>\n            ) : variant === \"single\" ? (\n              <Leaf progress={progress} hinge=\"left\" angle={angle} shading={shading} handle className=\"left-0 w-full\" rounded=\"rounded-[6px]\" />\n            ) : (\n              Array.from({ length: shutterCount }, (_, i) => <Slat key={i} progress={progress} index={i} total={shutterCount} shading={shading} />)\n            )}\n          </motion.div>\n        </div>\n        {/* Threshold */}\n        <div aria-hidden=\"true\" className=\"absolute inset-x-[10px] bottom-[4px] h-[2px] rounded-full bg-black/10 dark:bg-white/10\" />\n      </div>\n\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        aria-controls={contentId}\n        onClick={() => setOpen(!open)}\n        className=\"inline-flex h-9 items-center gap-2 rounded-full border border-zinc-900/15 bg-white px-4 text-[13px] font-medium text-zinc-900 outline-hidden transition-colors hover:bg-zinc-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dr-accent)] dark:border-white/15 dark:bg-[#1b1e22] dark:text-zinc-100 dark:hover:bg-[#22262b]\"\n      >\n        <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\">\n          {open ? <path d=\"M3 2.5h10v11H3zM8 2.5v11\" /> : <path d=\"M3 2.5h4l-1.5 1.5v8l1.5 1.5H3zM13 2.5H9l1.5 1.5v8L9 13.5h4z\" />}\n        </svg>\n        {open ? closeLabel : openLabel}\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/door-reveal"
}
