"use client";
import * as React from "react";
type FaceName = "front" | "back" | "right" | "left" | "top" | "bottom";
type Vec3 = [number, number, number];
export type StageLighting = "studio" | "warm" | "night";
export interface RotatingProductStageProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Product name printed on the front face and used in the accessible name. */
title?: string;
/** Brand mark printed on the faces. */
brand?: string;
/** Description read by assistive technology. */
description?: string;
lighting?: StageLighting;
/** Slowly turn the product while idle. */
autoRotate?: boolean;
/** Auto-rotation speed in degrees per second. */
autoRotateSpeed?: number;
/** Keep spinning after a flick, with friction. */
inertia?: boolean;
/** CSS perspective distance in px — lower is more dramatic. */
perspective?: number;
/** Degrees of rotation per pixel dragged. */
sensitivity?: number;
accent?: string;
/** Show the reset button and the interaction hint. */
showControls?: boolean;
/** Resting angle in degrees. */
initialRotation?: { x: number; y: number };
/** Replace the content of individual faces. */
faces?: Partial<Record<FaceName, React.ReactNode>>;
}
/* ----------------------------- Lighting model ----------------------------- */
interface LightingPreset {
name: string;
/** Unit vector pointing toward the light (screen space: +x right, +y down, +z toward viewer). */
direction: Vec3;
ambient: number;
shadeStrength: number;
specular: number;
specularColor: string;
body: string;
ink: string;
muted: string;
stage: string;
shadow: string;
}
const normalize = ([x, y, z]: Vec3): Vec3 => {
const l = Math.hypot(x, y, z) || 1;
return [x / l, y / l, z / l];
};
const LIGHTING: Record<StageLighting, LightingPreset> = {
studio: {
name: "Studio",
direction: normalize([-0.45, -0.75, 0.62]),
ambient: 0.6,
shadeStrength: 0.6,
specular: 0.55,
specularColor: "rgba(255,255,255,0.9)",
body: "#F1EFEA",
ink: "#17181A",
muted: "rgba(23,24,26,0.5)",
stage:
"bg-[radial-gradient(120%_90%_at_50%_0%,#ffffff_0%,#ECEAE4_52%,#D8D4CB_100%)] dark:bg-[radial-gradient(120%_90%_at_50%_0%,#33363b_0%,#1c1e21_55%,#111214_100%)]",
shadow: "rgba(20,18,14,0.38)",
},
warm: {
name: "Warm",
direction: normalize([0.7, -0.45, 0.55]),
ambient: 0.42,
shadeStrength: 0.7,
specular: 0.65,
specularColor: "rgba(255,214,170,0.95)",
body: "#E7D5BF",
ink: "#2A1D12",
muted: "rgba(42,29,18,0.5)",
stage:
"bg-[radial-gradient(110%_90%_at_80%_0%,#FFF3E4_0%,#F1D6B8_50%,#D9AE84_100%)] dark:bg-[radial-gradient(110%_90%_at_80%_0%,#4a3020_0%,#24170f_55%,#140d08_100%)]",
shadow: "rgba(70,38,12,0.42)",
},
night: {
name: "Night",
direction: normalize([-0.65, -0.35, 0.45]),
ambient: 0.26,
shadeStrength: 0.8,
specular: 0.85,
specularColor: "rgba(158,201,255,0.85)",
body: "#272A31",
ink: "#F1F2F4",
muted: "rgba(241,242,244,0.5)",
stage: "bg-[radial-gradient(110%_90%_at_20%_0%,#24304a_0%,#0e131d_55%,#06080c_100%)]",
shadow: "rgba(0,0,0,0.7)",
},
};
const FACE_NORMALS: Record<FaceName, Vec3> = {
front: [0, 0, 1],
back: [0, 0, -1],
right: [1, 0, 0],
left: [-1, 0, 0],
top: [0, -1, 0],
bottom: [0, 1, 0],
};
const FACE_NAMES = Object.keys(FACE_NORMALS) as FaceName[];
const VIEW_HALF: Vec3 = [0, 0, 1];
/** Applies CSS `rotateX(ax) rotateY(ay)` (rotateY first) to a vector. Angles in degrees. */
function rotate([x, y, z]: Vec3, ax: number, ay: number): Vec3 {
const ry = (ay * Math.PI) / 180;
const rx = (ax * Math.PI) / 180;
const x1 = Math.cos(ry) * x + Math.sin(ry) * z;
const z1 = -Math.sin(ry) * x + Math.cos(ry) * z;
return [x1, Math.cos(rx) * y - Math.sin(rx) * z1, Math.sin(rx) * y + Math.cos(rx) * z1];
}
const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/** Lambert diffuse + Blinn specular per face, expressed as overlay opacities. */
function shadeFaces(ax: number, ay: number, light: LightingPreset) {
const half = normalize([light.direction[0] + VIEW_HALF[0], light.direction[1] + VIEW_HALF[1], light.direction[2] + VIEW_HALF[2]]);
const out = {} as Record<FaceName, { shade: number; spec: number }>;
for (const face of FACE_NAMES) {
const n = rotate(FACE_NORMALS[face], ax, ay);
const diffuse = Math.max(0, dot(n, light.direction));
const brightness = light.ambient + (1 - light.ambient) * diffuse;
out[face] = {
shade: Math.min(0.9, (1 - brightness) * light.shadeStrength),
spec: Math.pow(Math.max(0, dot(n, half)), 18) * light.specular,
};
}
return out;
}
/* ------------------------------ Geometry -------------------------------- */
// Design units; the stage scales them with its container (1u = min(width, height) / 560).
const W = 200;
const H = 260;
const D = 120;
const u = (n: number) => `calc(var(--ps-u) * ${n})`;
const FACE_GEOMETRY: Record<FaceName, { w: number; h: number; transform: string }> = {
front: { w: W, h: H, transform: `translateZ(${u(D / 2)})` },
back: { w: W, h: H, transform: `rotateY(180deg) translateZ(${u(D / 2)})` },
right: { w: D, h: H, transform: `rotateY(90deg) translateZ(${u(W / 2)})` },
left: { w: D, h: H, transform: `rotateY(-90deg) translateZ(${u(W / 2)})` },
top: { w: W, h: D, transform: `rotateX(90deg) translateZ(${u(H / 2)})` },
bottom: { w: W, h: D, transform: `rotateX(-90deg) translateZ(${u(H / 2)})` },
};
const MONO = "font-mono uppercase";
function DefaultFace({ face, title, brand }: { face: FaceName; title: string; brand: string }) {
switch (face) {
case "front":
return (
<div className="flex h-full flex-col justify-between" style={{ padding: u(16) }}>
<div className="flex items-center justify-between">
<span className={MONO} style={{ fontSize: u(9), letterSpacing: "0.18em" }}>
{brand}
</span>
<span className="rounded-full bg-[var(--ps-accent)]" style={{ width: u(7), height: u(7) }} />
</div>
<div
className="mx-auto rounded-full"
style={{
width: u(132),
height: u(132),
backgroundImage: "radial-gradient(circle, var(--ps-muted) 30%, transparent 36%)",
backgroundSize: `${u(7)} ${u(7)}`,
boxShadow: "inset 0 0 0 1px var(--ps-muted)",
}}
/>
<div>
<div className="font-semibold tracking-[-0.03em]" style={{ fontSize: u(22), lineHeight: 1 }}>
{title}
</div>
<div className={`${MONO} mt-1 text-[var(--ps-muted)]`} style={{ fontSize: u(8), letterSpacing: "0.16em" }}>
Edition 2026 · Hand-finished
</div>
</div>
</div>
);
case "back":
return (
<div className="flex h-full flex-col justify-between" style={{ padding: u(16) }}>
<div className={`${MONO} space-y-1 text-[var(--ps-muted)]`} style={{ fontSize: u(8), letterSpacing: "0.14em" }}>
<div>Serial · WC-01-0425</div>
<div>Input · 24V ⎓ 1.5A</div>
<div>Designed in Kuching</div>
</div>
<div className="flex items-center" style={{ gap: u(8) }}>
{[18, 18, 30].map((w, i) => (
<span key={i} className="rounded-[2px] bg-black/50" style={{ width: u(w), height: u(9) }} />
))}
</div>
</div>
);
case "left":
case "right":
return (
<div className="flex h-full items-center justify-center">
<span
className={`${MONO} text-[var(--ps-muted)]`}
style={{ fontSize: u(8), letterSpacing: "0.3em", writingMode: "vertical-rl", transform: face === "left" ? "rotate(180deg)" : undefined }}
>
{brand} · {title}
</span>
<span className="absolute bg-[var(--ps-accent)]" style={{ width: u(2), height: u(40), bottom: u(18), left: "50%", marginLeft: u(-1) }} />
</div>
);
case "top":
return (
<div className="flex h-full items-center justify-center">
<span
className="relative rounded-full"
style={{
width: u(64),
height: u(64),
boxShadow: "inset 0 0 0 1px var(--ps-muted), 0 1px 0 rgba(255,255,255,0.25)",
background: "radial-gradient(circle at 35% 30%, rgba(255,255,255,0.35), transparent 60%)",
}}
>
<span className="absolute left-1/2 rounded-full bg-[var(--ps-accent)]" style={{ width: u(3), height: u(14), top: u(6), marginLeft: u(-1.5) }} />
</span>
</div>
);
default:
return null;
}
}
/* -------------------------------------------------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const clampTilt = (x: number) => Math.max(-70, Math.min(70, x));
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
/**
* A CSS 3D product stage: drag (with inertia) or use the arrow keys to turn a lit, labelled box.
* Per-face shading is computed from the light direction and current rotation, written to CSS
* variables in a requestAnimationFrame loop that sleeps whenever nothing is moving.
*/
export function RotatingProductStage({
title = "Model 01",
brand = "Wancraft",
description = "A matte compact speaker with a perforated front grille, a top-mounted dial and an accent status light.",
lighting = "studio",
autoRotate = true,
autoRotateSpeed = 14,
inertia = true,
perspective = 1100,
sensitivity = 0.4,
accent = "#FF7448",
showControls = true,
initialRotation = { x: -16, y: -32 },
faces,
className = "",
style,
...props
}: RotatingProductStageProps) {
const reduceMotion = usePrefersReducedMotion();
const descId = React.useId();
const light = LIGHTING[lighting] ?? LIGHTING.studio;
const initX = initialRotation.x;
const initY = initialRotation.y;
const objectRef = React.useRef<HTMLDivElement>(null);
const rotation = React.useRef({ x: initX, y: initY, vx: 0, vy: 0 });
const dragRef = React.useRef<{ id: number; x: number; y: number; t: number } | null>(null);
const tweenRef = React.useRef<{ fx: number; fy: number; tx: number; ty: number; t0: number; dur: number } | null>(null);
const idleUntil = React.useRef(0);
const kickRef = React.useRef<(() => void) | null>(null);
const [dragging, setDragging] = React.useState(false);
// Initial shading for the server render; the loop takes over on the client.
const initialShade = shadeFaces(initX, initY, light);
// Layout effect: re-applies the live rotation before paint whenever lighting or settings change.
React.useLayoutEffect(() => {
const object = objectRef.current;
if (!object) return;
const faceEls = FACE_NAMES.map((name) => object.querySelector<HTMLElement>(`[data-face="${name}"]`));
const animateIdle = autoRotate && !reduceMotion;
let frame = 0;
let running = false;
let visible = true;
let last = 0;
const apply = () => {
const { x, y } = rotation.current;
object.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
const shading = shadeFaces(x, y, light);
faceEls.forEach((el, i) => {
if (!el) return;
const s = shading[FACE_NAMES[i]];
el.style.setProperty("--ps-shade", s.shade.toFixed(3));
el.style.setProperty("--ps-spec", s.spec.toFixed(3));
});
};
const loop = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
const r = rotation.current;
let active = false;
if (dragRef.current) {
active = true; // pointer handlers move the object; we only render
} else if (tweenRef.current) {
const tw = tweenRef.current;
const t = tw.dur <= 0 ? 1 : Math.min(1, (now - tw.t0) / tw.dur);
const e = easeOutCubic(t);
r.x = tw.fx + (tw.tx - tw.fx) * e;
r.y = tw.fy + (tw.ty - tw.fy) * e;
if (t >= 1) tweenRef.current = null;
active = true;
} else {
if (inertia && !reduceMotion && (Math.abs(r.vx) > 0.5 || Math.abs(r.vy) > 0.5)) {
r.y += r.vy * dt;
r.x = clampTilt(r.x + r.vx * dt);
const friction = Math.pow(0.035, dt); // keeps ~3.5% of velocity per second
r.vx *= friction;
r.vy *= friction;
active = true;
} else {
r.vx = 0;
r.vy = 0;
}
if (animateIdle) {
if (now > idleUntil.current) r.y += autoRotateSpeed * dt;
active = true;
}
}
apply();
if (active && visible && !document.hidden) {
frame = requestAnimationFrame(loop);
} else {
running = false;
last = 0;
}
};
const kick = () => {
if (running || !visible || document.hidden) return;
running = true;
last = 0;
frame = requestAnimationFrame(loop);
};
kickRef.current = kick;
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) kick();
});
io.observe(object.parentElement ?? object);
const onVisibility = () => {
if (!document.hidden) kick();
};
document.addEventListener("visibilitychange", onVisibility);
apply();
kick();
return () => {
cancelAnimationFrame(frame);
running = false;
kickRef.current = null;
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [light, autoRotate, autoRotateSpeed, inertia, reduceMotion]);
const tweenTo = (tx: number, ty: number, dur = 520) => {
const r = rotation.current;
const now = performance.now();
tweenRef.current = { fx: r.x, fy: r.y, tx: clampTilt(tx), ty, t0: now, dur: reduceMotion ? 0 : dur };
r.vx = 0;
r.vy = 0;
idleUntil.current = now + 2500;
kickRef.current?.();
};
const reset = () => {
const r = rotation.current;
// Return by the shortest way round instead of unwinding every turn.
const ty = initY + 360 * Math.round((r.y - initY) / 360);
tweenTo(initX, ty, 800);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp };
tweenRef.current = null;
rotation.current.vx = 0;
rotation.current.vy = 0;
setDragging(true);
kickRef.current?.();
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
const dt = Math.max(1, event.timeStamp - drag.t) / 1000;
const r = rotation.current;
r.y += dx * sensitivity;
r.x = clampTilt(r.x - dy * sensitivity);
// Smoothed velocity in degrees per second, used for the inertial flick.
r.vy = r.vy * 0.6 + ((dx * sensitivity) / dt) * 0.4;
r.vx = r.vx * 0.6 + ((-dy * sensitivity) / dt) * 0.4;
dragRef.current = { id: drag.id, x: event.clientX, y: event.clientY, t: event.timeStamp };
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
// A pause before release means no flick.
if (event.timeStamp - drag.t > 90) {
rotation.current.vx = 0;
rotation.current.vy = 0;
}
dragRef.current = null;
idleUntil.current = performance.now() + 2500;
setDragging(false);
kickRef.current?.();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const r = rotation.current;
const base = tweenRef.current ? { x: tweenRef.current.tx, y: tweenRef.current.ty } : { x: r.x, y: r.y };
const step = event.shiftKey ? 90 : 30;
const actions: Record<string, () => void> = {
ArrowLeft: () => tweenTo(base.x, base.y - step),
ArrowRight: () => tweenTo(base.x, base.y + step),
ArrowUp: () => tweenTo(base.x - 15, base.y),
ArrowDown: () => tweenTo(base.x + 15, base.y),
Home: reset,
};
const action = actions[event.key];
if (!action) return;
event.preventDefault();
action();
};
const accessibleName = `${brand} ${title}`;
return (
<div
className={`relative h-full min-h-[260px] w-full overflow-hidden [container-type:size] ${light.stage} ${className}`}
style={{ ["--ps-accent" as string]: accent, ...style }}
{...props}
>
<div
tabIndex={0}
role="group"
aria-roledescription="3D product viewer"
aria-label={accessibleName}
aria-describedby={descId}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={handleKeyDown}
className={`absolute inset-0 touch-pan-y select-none outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--ps-accent)] ${
dragging ? "cursor-grabbing" : "cursor-grab"
}`}
style={{
["--ps-u" as string]: "calc(min(100cqw, 100cqh) / 560)",
["--ps-ink" as string]: light.ink,
["--ps-muted" as string]: light.muted,
}}
>
{/* Contact shadow */}
<div
aria-hidden="true"
className="absolute left-1/2 top-1/2 -translate-x-1/2 rounded-[50%] blur-[14px]"
style={{ width: u(260), height: u(46), marginTop: u(150), background: `radial-gradient(closest-side, ${light.shadow}, transparent)` }}
/>
<div
role="img"
aria-label={description}
className="absolute inset-0 grid place-items-center"
style={{ perspective: `${perspective}px` }}
>
<div
ref={objectRef}
aria-hidden="true"
className="relative [transform-style:preserve-3d]"
style={{ width: 0, height: 0, transform: `rotateX(${initX}deg) rotateY(${initY}deg)` }}
>
{FACE_NAMES.map((face) => {
const g = FACE_GEOMETRY[face];
return (
<div
key={face}
data-face={face}
className="absolute left-0 top-0 overflow-hidden text-[var(--ps-ink)] [backface-visibility:hidden]"
style={{
width: u(g.w),
height: u(g.h),
marginLeft: u(-g.w / 2),
marginTop: u(-g.h / 2),
transform: g.transform,
backgroundColor: light.body,
borderRadius: u(face === "front" || face === "back" ? 6 : 3),
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.18), inset 0 0 0 2px rgba(0,0,0,0.04)",
["--ps-shade" as string]: initialShade[face].shade.toFixed(3),
["--ps-spec" as string]: initialShade[face].spec.toFixed(3),
}}
>
{faces?.[face] ?? <DefaultFace face={face} title={title} brand={brand} />}
{/* Diffuse shading and specular highlight, driven by CSS variables. */}
<div className="pointer-events-none absolute inset-0 bg-black" style={{ opacity: "var(--ps-shade)" }} />
<div
className="pointer-events-none absolute inset-0 mix-blend-screen"
style={{
opacity: "var(--ps-spec)",
background: `radial-gradient(120% 90% at 30% 15%, ${light.specularColor}, transparent 65%)`,
}}
/>
</div>
);
})}
</div>
</div>
</div>
<p id={descId} className="sr-only">
{`${description} Drag, or use the arrow keys, to rotate. Hold Shift for quarter turns. Press Home to reset the view.`}
</p>
{showControls ? (
<>
<div
aria-hidden="true"
className={`pointer-events-none absolute left-4 top-4 font-mono text-[10px] uppercase tracking-[0.14em] ${
lighting === "night" ? "text-white/60" : "text-zinc-900/55 dark:text-white/55"
}`}
>
{light.name} light
</div>
<div
aria-hidden="true"
className={`pointer-events-none absolute bottom-4 left-4 font-mono text-[10px] uppercase tracking-[0.14em] ${
lighting === "night" ? "text-white/50" : "text-zinc-900/45 dark:text-white/45"
}`}
>
Drag · ← → ↑ ↓
</div>
<button
type="button"
onClick={reset}
className={`absolute bottom-3 right-3 inline-flex h-8 items-center gap-1.5 rounded-full border px-3 font-mono text-[10px] uppercase tracking-[0.14em] backdrop-blur-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ps-accent)] ${
lighting === "night"
? "border-white/15 bg-white/5 text-white/80 hover:border-white/40 hover:text-white"
: "border-zinc-900/15 bg-white/50 text-zinc-700 hover:border-zinc-900/40 hover:text-zinc-950 dark:border-white/15 dark:bg-white/5 dark:text-white/80 dark:hover:border-white/40 dark:hover:text-white"
}`}
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
<path d="M2.2 6a3.8 3.8 0 1 0 1.1-2.7" strokeLinecap="round" />
<path d="M2 1.8v2.2h2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Reset view
</button>
</>
) : null}
</div>
);
}