"use client";
import * as React from "react";
import { useScroll } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface DepthGalleryItem {
id: string;
content: React.ReactNode;
/** Short title used in announcements ("Item 3 of 8: title") and item names. */
label: string;
}
export interface DepthGalleryProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
items: DepthGalleryItem[];
/** Scrollable ancestor that drives the gallery (make it `position: relative`). Defaults to the window. */
container?: React.RefObject<HTMLElement | null>;
/** Accessible name of the gallery. */
label?: string;
/** Card width in px. */
cardWidth?: number;
/** Card height in px. Defaults to 1.25 × cardWidth. */
cardHeight?: number;
/** Distance between neighbouring items along the Z axis, in px. */
depthGap?: number;
/** Radius of the loose spiral the items sit on, in px. 0 lines them up. */
spread?: number;
/** CSS perspective in px — lower is more dramatic. */
perspective?: number;
/** Scroll distance that moves the camera from one item to the next, in px. */
scrollPerItem?: number;
/** Previous / next buttons and the counter. */
showControls?: boolean;
/** Focused-card edge, focus and control colour (any CSS colour). */
accent?: string;
}
const GOLDEN_ANGLE = (137.5 * Math.PI) / 180;
const r2 = (n: number) => Math.round(n * 100) / 100;
const clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n);
/** Spiral offset of item i in px (golden-angle spiral, flattened vertically). */
function spiral(i: number, spread: number): [number, number] {
if (i === 0 || spread === 0) return [0, 0];
const a = i * GOLDEN_ANGLE;
return [Math.cos(a) * spread, Math.sin(a) * spread * 0.7];
}
/**
* Where item i sits when the camera is at `t` (in item units, 0 = first item in focus).
* The camera pans part of the way toward the spiral offset of the focused item, so cards drift
* past the viewer rather than lining up dead centre.
*/
function place(i: number, t: number, depthGap: number, spread: number, perspective: number) {
const lo = Math.floor(t);
const f = t - lo;
const [ax, ay] = spiral(lo, spread);
const [bx, by] = spiral(lo + 1, spread);
const camX = (ax + (bx - ax) * f) * 0.7;
const camY = (ay + (by - ay) * f) * 0.7;
const [x, y] = spiral(i, spread);
const rel = i - t; // > 0: ahead of the camera (farther), < 0: passed
const z = -rel * depthGap;
// Fade out as a card flies past the viewer, and into the fog when far away.
const near = clamp(1 + rel / 0.6, 0, 1);
const far = clamp((5 - rel) / 2, 0, 1);
const tooClose = z > perspective * 0.8 ? 0 : 1;
const opacity = r2(near * far * tooClose);
return {
transform: `translate3d(${r2(x - camX)}px, ${r2(y - camY)}px, ${r2(z)}px)`,
opacity,
visibility: opacity < 0.02 ? ("hidden" as const) : ("visible" as const),
};
}
const iconButton =
"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white/90 text-zinc-700 outline-hidden backdrop-blur-sm transition-colors hover:border-zinc-900/25 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dg-accent)] aria-disabled:cursor-default aria-disabled:opacity-40 dark:border-white/12 dark:bg-zinc-900/90 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white";
function Arrow({ dir }: { dir: "back" | "forward" }) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={dir === "back" ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} />
</svg>
);
}
/**
* A perspective tunnel: cards sit on a loose spiral at increasing depth and fly toward the viewer
* as the page (or a scrollable container) scrolls. A tall track supplies the scroll distance and
* a sticky viewport holds the 3D stage; transforms are written straight to the DOM from the
* scroll progress, so scrolling never re-renders React. With reduced motion it is a plain list.
*/
export function DepthGallery({
items,
container,
label = "Gallery",
cardWidth = 240,
cardHeight: cardHeightProp,
depthGap = 420,
spread = 220,
perspective = 900,
scrollPerItem = 360,
showControls = true,
accent = "#FF7448",
className = "",
style,
...props
}: DepthGalleryProps) {
const reduceMotion = usePrefersReducedMotion();
const n = items.length;
const cardHeight = cardHeightProp ?? Math.round(cardWidth * 1.25);
const rootRef = React.useRef<HTMLElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const indexRef = React.useRef(0);
const [index, setIndex] = React.useState(0);
const { scrollYProgress } = useScroll({ target: trackRef, container, offset: ["start start", "end end"] });
const current = n ? Math.min(index, n - 1) : 0;
// The sticky viewport is exactly one scroller-height tall.
React.useEffect(() => {
const root = rootRef.current;
if (!root || reduceMotion) return;
const scroller = container?.current ?? null;
const measure = () => {
const h = scroller ? scroller.clientHeight : window.innerHeight;
root.style.setProperty("--dg-view", `${h}px`);
};
measure();
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
}, [container, reduceMotion]);
// Scroll progress → camera position → DOM writes for every card.
React.useEffect(() => {
if (reduceMotion || !n) return;
const update = (p: number) => {
const t = clamp(p, 0, 1) * (n - 1);
itemRefs.current.forEach((el, i) => {
if (!el) return;
const s = place(i, t, depthGap, spread, perspective);
el.style.transform = s.transform;
el.style.opacity = String(s.opacity);
el.style.visibility = s.visibility;
});
const idx = Math.round(t);
if (idx !== indexRef.current) {
indexRef.current = idx;
setIndex(idx);
}
};
update(scrollYProgress.get());
return scrollYProgress.on("change", update);
}, [scrollYProgress, n, depthGap, spread, perspective, reduceMotion]);
/** Scrolls so that item k is in focus. */
const goTo = (k: number) => {
const track = trackRef.current;
if (!track) return;
const target = clamp(k, 0, n - 1);
const scroller = container?.current ?? null;
const rect = track.getBoundingClientRect();
const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth";
if (scroller) {
const box = scroller.getBoundingClientRect();
scroller.scrollTo({ top: rect.top - box.top - scroller.clientTop + scroller.scrollTop + target * scrollPerItem, behavior });
} else {
window.scrollTo({ top: rect.top + window.scrollY + target * scrollPerItem, behavior });
}
};
// An empty gallery renders nothing visible, but keeps useScroll's target mounted.
if (!n) return <div ref={trackRef} hidden />;
if (reduceMotion) {
return (
<section ref={rootRef} aria-label={label} className={`w-full ${className}`} style={{ ["--dg-accent" as string]: accent, ...style }} {...props}>
{/* useScroll still needs its target mounted, even though nothing is scroll-linked here. */}
<div ref={trackRef}>
<ol className="mx-auto flex flex-col items-center gap-6 py-6">
{items.map((item, i) => (
<li
key={item.id}
aria-label={`${i + 1} of ${n}: ${item.label}`}
className="max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ width: cardWidth, height: cardHeight }}
>
{item.content}
</li>
))}
</ol>
</div>
</section>
);
}
const item = items[current];
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`relative w-full ${className}`}
style={{ ["--dg-accent" as string]: accent, ["--dg-view" as string]: "100svh", ...style }}
{...props}
>
<div ref={trackRef} className="relative" style={{ height: `calc(var(--dg-view) + ${(n - 1) * scrollPerItem}px)` }}>
<div className="sticky top-0 w-full overflow-hidden" style={{ height: "var(--dg-view)" }}>
<div className="absolute inset-0 grid place-items-center">
{/* Perspective sits on the cards' direct parent, so translateZ scales them. */}
<ol className="relative" style={{ width: cardWidth, height: cardHeight, perspective }}>
{items.map((it, i) => {
const s = place(i, 0, depthGap, spread, perspective);
const focused = i === current;
return (
<li
key={it.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
aria-roledescription="slide"
aria-label={`${i + 1} of ${n}: ${it.label}`}
aria-hidden={!focused}
inert={!focused}
className={`absolute inset-0 overflow-hidden rounded-xl border bg-white shadow-[0_30px_60px_-30px_rgba(0,0,0,0.45)] will-change-transform dark:bg-zinc-900 ${
focused ? "border-[color-mix(in_srgb,var(--dg-accent)_75%,transparent)]" : "border-zinc-900/10 dark:border-white/10"
}`}
style={{ zIndex: n - i, transform: s.transform, opacity: s.opacity, visibility: s.visibility }}
>
{it.content}
</li>
);
})}
</ol>
</div>
{showControls && (
<div className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-2 px-4">
<button type="button" aria-label="Previous item" aria-disabled={current === 0} onClick={() => current > 0 && goTo(current - 1)} className={iconButton}>
<Arrow dir="back" />
</button>
<span
aria-hidden="true"
className="min-w-[9rem] rounded-full border border-zinc-900/10 bg-white/85 px-3 py-1.5 text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 backdrop-blur-sm dark:border-white/10 dark:bg-zinc-950/80 dark:text-zinc-400"
>
<span className="text-zinc-900 dark:text-zinc-100">{String(current + 1).padStart(2, "0")}</span> / {String(n).padStart(2, "0")} · {item.label}
</span>
<button type="button" aria-label="Next item" aria-disabled={current === n - 1} onClick={() => current < n - 1 && goTo(current + 1)} className={iconButton}>
<Arrow dir="forward" />
</button>
</div>
)}
</div>
</div>
<div className="sr-only" aria-live="polite" aria-atomic="true">
{`Item ${current + 1} of ${n}: ${item.label}`}
</div>
</section>
);
}