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