"use client";
import * as React from "react";
import { animate, motion, useMotionValue, type AnimationPlaybackControls } 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,
);
}
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
export interface CubeCarouselItem {
id: string;
content: React.ReactNode;
/** Short title used in announcements ("Face 2 of 4: title") and slide names. */
label: string;
}
export interface CubeSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface CubeCarouselProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
/**
* Slides. The cube has four physical side faces; with more than four items the faces are
* reused — the two faces beside the front one are refilled with the previous/next items
* whenever the cube settles on a new face, and the hidden back face is refilled silently.
*/
items: CubeCarouselItem[];
/** Accessible name of the carousel. */
label?: string;
/** Face width in px (also the cube's depth). */
width?: number;
/** Face height in px. Defaults to 0.7 × width. */
height?: number;
/** CSS perspective in px — lower is more dramatic. */
perspective?: number;
/** Cube tilt in degrees; negative looks down onto the top. */
tilt?: number;
/** Spring used when the cube turns and snaps. */
spring?: CubeSpring;
/** Turn to the next face on an interval (pauses on hover, focus, offscreen and hidden tabs). */
autoRotate?: boolean;
/** Auto-rotate interval in ms. */
interval?: number;
/** Previous / pause / next buttons and the counter. */
showControls?: boolean;
/** Front-face edge, focus and control colour (any CSS colour). */
accent?: string;
/** Initially shown item. */
defaultIndex?: number;
/** Called when the front item changes. */
onIndexChange?: (index: number) => void;
}
const FACES = [0, 1, 2, 3] as const;
const mod = (n: number, m: number) => ((n % m) + m) % m;
const iconButton =
"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-hidden 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(--cc-accent)] dark:border-white/12 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white";
function Chevron({ dir }: { dir: "left" | "right" }) {
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 === "left" ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} />
</svg>
);
}
/**
* A CSS 3D cube whose four side faces hold slides. Turn it with the buttons, the arrow keys
* (while the stage is focused) or by dragging — the release velocity carries into a spring that
* snaps to the nearest face. Only the front face is interactive; the others are inert and hidden
* from assistive tech. With reduced motion the cube becomes a flat panel that crossfades.
*/
export function CubeCarousel({
items,
label = "Featured",
width = 300,
height: heightProp,
perspective = 1100,
tilt = -8,
spring = { stiffness: 170, damping: 22, mass: 1 },
autoRotate = true,
interval = 3600,
showControls = true,
accent = "#FF7448",
defaultIndex = 0,
onIndexChange,
className = "",
style,
...props
}: CubeCarouselProps) {
const reduceMotion = usePrefersReducedMotion();
const n = items.length;
const height = heightProp ?? Math.round(width * 0.7);
const half = width / 2;
const startPos = n ? mod(Math.round(defaultIndex), n) : 0;
const { stiffness, damping, mass } = spring;
const rootRef = React.useRef<HTMLElement>(null);
const stageRef = React.useRef<HTMLDivElement>(null);
const faceRefs = React.useRef<(HTMLDivElement | null)[]>([]);
// Rotation in degrees. Position p (an unbounded integer) sits at -p × 90°.
const angle = useMotionValue(-startPos * 90);
const anim = React.useRef<AnimationPlaybackControls | null>(null);
const target = React.useRef<number | null>(null);
const scale = React.useRef(1);
const posRef = React.useRef(startPos);
const onIndexChangeRef = React.useRef(onIndexChange);
const drag = React.useRef<{ id: number; x: number; t: number; startX: number; start: number; v: number; moved: boolean } | null>(null);
const suppressClick = React.useRef(false);
const [pos, setPos] = React.useState(startPos);
const [flatIndex, setFlatIndex] = React.useState(startPos);
const [dragging, setDragging] = React.useState(false);
const [hovering, setHovering] = React.useState(false);
const [focusWithin, setFocusWithin] = React.useState(false);
const [userPaused, setUserPaused] = React.useState(false);
const [inView, setInView] = React.useState(true);
const hidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
const flat = reduceMotion;
const front = n ? (flat ? mod(flatIndex, n) : mod(pos, n)) : 0;
const frontFace = mod(pos, 4);
const running = autoRotate && !flat && n > 1 && !userPaused && !hovering && !focusWithin && !dragging && inView && !hidden;
React.useEffect(() => {
onIndexChangeRef.current = onIndexChange;
});
// Track the settled position and face shading from the angle — DOM writes, no per-frame state.
React.useEffect(() => {
if (!n) return;
const update = (a: number) => {
faceRefs.current.forEach((el, f) => {
if (!el) return;
const rel = ((f * 90 + a) * Math.PI) / 180;
el.style.setProperty("--cc-dim", Math.min(0.6, (1 - Math.cos(rel)) * 0.4).toFixed(3));
});
const p = Math.round(-a / 90);
if (p !== posRef.current) {
const prevIndex = mod(posRef.current, n);
posRef.current = p;
setPos(p);
if (mod(p, n) !== prevIndex) onIndexChangeRef.current?.(mod(p, n));
}
};
update(angle.get());
return angle.on("change", update);
}, [angle, n]);
// Pause when offscreen; fit the cube to narrow containers by scaling the scene.
React.useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root) return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(root);
const ro = new ResizeObserver(() => {
if (!stage) return;
const s = Math.min(1, stage.clientWidth / (width * 1.5 + 24));
scale.current = s;
root.style.setProperty("--cc-scale", s.toFixed(3));
});
if (stage) ro.observe(stage);
return () => {
io.disconnect();
ro.disconnect();
};
}, [width, flat]);
const turnTo = (to: number, velocity = 0) => {
anim.current?.stop();
target.current = to;
anim.current = animate(angle, to, { type: "spring", stiffness, damping, mass, velocity });
};
/** Turns by whole faces from the current target (so quick repeated presses accumulate). */
const turnBy = (delta: number) => {
if (n < 2) return;
const base = target.current ?? angle.get();
turnTo(Math.round(base / 90) * 90 - delta * 90);
};
const step = (delta: number) => {
if (flat) setFlatIndex((i) => mod(i + delta, n));
else turnBy(delta);
};
// Auto-rotation: one face per interval while nothing asks it to pause.
React.useEffect(() => {
if (!running) return;
const t = setInterval(() => {
anim.current?.stop();
const base = target.current ?? angle.get();
const to = Math.round(base / 90) * 90 - 90;
target.current = to;
anim.current = animate(angle, to, { type: "spring", stiffness, damping, mass });
}, Math.max(1500, interval));
return () => clearInterval(t);
}, [running, interval, angle, stiffness, damping, mass]);
React.useEffect(() => () => anim.current?.stop(), []);
/* --------------------------------- Drag --------------------------------- */
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || n < 2) return;
suppressClick.current = false;
drag.current = { id: e.pointerId, x: e.clientX, t: e.timeStamp, startX: e.clientX, start: angle.get(), v: 0, moved: false };
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
if (!d.moved) {
if (Math.abs(e.clientX - d.startX) < 6) return;
// Only now capture, so a plain click still reaches the face content.
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
anim.current?.stop();
target.current = null;
d.start = angle.get();
d.startX = e.clientX;
setDragging(true);
}
const degPerPx = 90 / (width * scale.current);
const dt = Math.max(1, e.timeStamp - d.t) / 1000;
d.v = d.v * 0.6 + (((e.clientX - d.x) * degPerPx) / dt) * 0.4;
d.x = e.clientX;
d.t = e.timeStamp;
angle.set(d.start + (e.clientX - d.startX) * degPerPx);
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.moved) return;
suppressClick.current = true;
setDragging(false);
const velocity = e.timeStamp - d.t > 80 ? 0 : d.v; // a pause before release means no flick
const a = angle.get();
// Project the flick by at most one face from where the drag started, then snap.
const projected = Math.max(-60, Math.min(60, velocity * 0.12));
const snapped = Math.round((a + projected) / 90) * 90;
const from = Math.round(d.start / 90) * 90;
turnTo(Math.max(from - 90, Math.min(from + 90, snapped)), velocity);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
step(e.key === "ArrowRight" ? 1 : -1);
};
if (!n) return null;
const current = items[front];
/** Item shown on physical face f: front, its neighbours (±1) and the hidden back (+2). */
const itemForFace = (f: number) => {
const offset = mod(f - pos, 4);
return mod(pos + (offset === 3 ? -1 : offset), n);
};
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`relative w-full ${className}`}
style={{ ["--cc-accent" as string]: accent, ...style }}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovering(true)}
onPointerLeave={() => setHovering(false)}
onFocus={(e) => {
// Keyboard focus pauses rotation; a mouse click on a button doesn't.
if (e.target.matches(":focus-visible")) setFocusWithin(true);
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
}}
{...props}
>
{flat ? (
<div
role="group"
aria-label={`${label}: use the Left and Right arrow keys to change slides`}
tabIndex={0}
onKeyDown={onKeyDown}
className="mx-auto grid max-w-full overflow-hidden rounded-md border border-zinc-900/10 bg-white outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cc-accent)] dark:border-white/10 dark:bg-zinc-900"
style={{ width, height }}
>
{items.map((item, i) => (
<div
key={item.id}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${n}: ${item.label}`}
aria-hidden={i !== front}
inert={i !== front}
className={`[grid-area:1/1] min-h-0 min-w-0 transition-opacity duration-300 ${i === front ? "opacity-100" : "opacity-0"}`}
>
{item.content}
</div>
))}
</div>
) : (
<div
ref={stageRef}
role="group"
aria-label={`${label}: use the Left and Right arrow keys to turn the cube`}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={(e) => {
// A drag that ends over a face must not also click it.
if (suppressClick.current) {
e.preventDefault();
e.stopPropagation();
suppressClick.current = false;
}
}}
className={`relative grid w-full touch-pan-y select-none place-items-center overflow-hidden rounded-2xl outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cc-accent)] ${
n > 1 ? (dragging ? "cursor-grabbing" : "cursor-grab") : ""
}`}
style={{ height: `calc(${Math.round(height + width * 0.45)}px * var(--cc-scale, 1) + 1rem)` }}
>
{/* Floor shadow. */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 h-8 -translate-x-1/2 rounded-[50%] bg-black/[0.14] blur-2xl dark:bg-black/55"
style={{ width: `calc(${Math.round(width * 1.3)}px * var(--cc-scale, 1))`, marginTop: `calc(${Math.round(height / 2 + 10)}px * var(--cc-scale, 1))` }}
/>
<div className="relative [transform-style:preserve-3d]" style={{ width, height, perspective, scale: "var(--cc-scale, 1)" }}>
<motion.div className="absolute inset-0 [transform-style:preserve-3d]" style={{ z: -half, rotateX: tilt, rotateY: angle }}>
{FACES.map((f) => {
const isFront = f === frontFace;
const index = itemForFace(f);
const item = items[index];
return (
<div
key={f}
ref={(el) => {
faceRefs.current[f] = el;
}}
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} of ${n}: ${item.label}`}
aria-hidden={!isFront}
inert={!isFront}
className={`absolute inset-0 overflow-hidden rounded-[3px] border bg-white [backface-visibility:hidden] dark:bg-zinc-900 ${
isFront ? "border-[color-mix(in_srgb,var(--cc-accent)_70%,transparent)]" : "border-zinc-900/12 dark:border-white/12"
}`}
style={{ transform: `rotateY(${f * 90}deg) translateZ(${half}px)` }}
>
<div key={item.id} className="h-full w-full">
{item.content}
</div>
{/* Face shading by angle to the viewer. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-zinc-950" style={{ opacity: "var(--cc-dim, 0)" }} />
</div>
);
})}
{/* Top and bottom caps, so a tilted cube reads as solid. */}
{[90, -90].map((rx) => (
<div
key={rx}
aria-hidden="true"
className="absolute left-0 rounded-[3px] border border-zinc-900/10 bg-zinc-100 [backface-visibility:hidden] dark:border-white/10 dark:bg-zinc-800"
style={{ top: (height - width) / 2, width, height: width, transform: `rotateX(${rx}deg) translateZ(${height / 2}px)` }}
/>
))}
</motion.div>
</div>
</div>
)}
{showControls && (
<div className="mt-4 flex items-center justify-center gap-2">
<button type="button" aria-label="Previous slide" onClick={() => step(-1)} className={iconButton}>
<Chevron dir="left" />
</button>
{autoRotate && !flat && (
<button
type="button"
aria-label={userPaused ? "Start automatic rotation" : "Stop automatic rotation"}
onClick={() => setUserPaused((p) => !p)}
className={iconButton}
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
{userPaused ? <path d="M5 3.2v9.6c0 .5.5.8.9.5l7.2-4.8c.4-.3.4-.8 0-1L5.9 2.7c-.4-.3-.9 0-.9.5Z" /> : <path d="M4.5 3h2.2v10H4.5zM9.3 3h2.2v10H9.3z" />}
</svg>
</button>
)}
<span aria-hidden="true" className="min-w-[9rem] text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-900 dark:text-zinc-100">{String(front + 1).padStart(2, "0")}</span> / {String(n).padStart(2, "0")} · {current.label}
</span>
<button type="button" aria-label="Next slide" onClick={() => step(1)} className={iconButton}>
<Chevron dir="right" />
</button>
</div>
)}
{/* Announces the front face; silent while rotating automatically. */}
<div className="sr-only" aria-live={running ? "off" : "polite"} aria-atomic="true">
{`Face ${front + 1} of ${n}: ${current.label}`}
</div>
</section>
);
}