"use client";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
export interface RotundaProps {
children?: ReactNode;
radius?: number;
perspective?: number;
autoSpin?: number;
friction?: number;
snap?: boolean;
snapStiffness?: number;
depthDim?: number;
depthBlur?: number;
faceCamera?: boolean;
dragSensitivity?: number;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const SETTLE_SPEED = 24;
const DRAG_THRESHOLD = 5;
const Rotunda = memo(
({
children,
radius = 380,
perspective = 1200,
autoSpin = 0.06,
friction = 0.94,
snap = true,
snapStiffness = 140,
depthDim = 0.65,
depthBlur = 3,
faceCamera = false,
dragSensitivity = 0.35,
paused = false,
reducedMotion = false,
className,
}: RotundaProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const panelsRef = useRef<(HTMLDivElement | null)[]>([]);
const innersRef = useRef<(HTMLDivElement | null)[]>([]);
const items = Array.isArray(children) ? children : [children];
const count = Math.max(items.length, 1);
const motion = useRef({
angle: 0,
velocity: 0,
pending: false,
dragging: false,
moved: false,
hovering: false,
pointerId: -1,
downX: 0,
grabX: 0,
grabAngle: 0,
lastX: 0,
lastT: 0,
});
const live = useRef({
radius, perspective, autoSpin, friction, snap, snapStiffness,
depthDim, depthBlur, faceCamera, dragSensitivity, count, reducedMotion,
});
live.current = {
radius, perspective, autoSpin, friction, snap, snapStiffness,
depthDim, depthBlur, faceCamera, dragSensitivity, count, reducedMotion,
};
const apply = useRef<(() => void) | null>(null);
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
onFrame: ({ dt }) => {
const l = live.current;
const m = motion.current;
const step = 360 / l.count;
if (!m.dragging) {
if (!l.reducedMotion && !m.hovering && Math.abs(m.velocity) < SETTLE_SPEED) {
m.angle += l.autoSpin * 18 * dt;
}
const settling = l.snap && Math.abs(m.velocity) < SETTLE_SPEED;
if (settling) {
const target = Math.round(m.angle / step) * step;
const damping = 2 * Math.sqrt(l.snapStiffness);
const accel =
-l.snapStiffness * (m.angle - target) - damping * m.velocity;
m.velocity += accel * dt;
m.angle += m.velocity * dt;
} else {
m.angle += m.velocity * dt;
m.velocity *= Math.pow(l.friction, dt * 60);
}
}
apply.current?.();
},
deps: [],
});
useEffect(() => {
apply.current = () => {
const l = live.current;
const m = motion.current;
const step = 360 / l.count;
for (let i = 0; i < l.count; i++) {
const panel = panelsRef.current[i];
if (!panel) continue;
const theta = i * step + m.angle;
const rad = (theta * Math.PI) / 180;
const front = (Math.cos(rad) + 1) * 0.5;
panel.style.transform = l.faceCamera
? `translate(-50%, -50%) rotateY(${theta}deg) translateZ(${l.radius}px) rotateY(${-theta}deg)`
: `translate(-50%, -50%) rotateY(${theta}deg) translateZ(${l.radius}px)`;
panel.style.zIndex = String(Math.round(front * 100));
panel.style.opacity = String(1 - l.depthDim * (1 - front));
panel.style.pointerEvents = front > 0.5 ? "auto" : "none";
const inner = innersRef.current[i];
if (inner) {
const blur = l.depthBlur * (1 - front);
inner.style.filter = blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : "none";
}
}
};
apply.current();
loop.paint();
return () => {
apply.current = null;
};
}, [loop]);
useEffect(() => {
apply.current?.();
loop.paint();
}, [
radius, perspective, autoSpin, friction, snap, snapStiffness,
depthDim, depthBlur, faceCamera, dragSensitivity, count, loop,
]);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const m = motion.current;
m.pending = true;
m.dragging = false;
m.moved = false;
m.hovering = true;
m.pointerId = e.pointerId;
m.downX = e.clientX;
m.grabX = e.clientX;
m.grabAngle = m.angle;
m.lastX = e.clientX;
m.lastT = e.timeStamp;
m.velocity = 0;
window.getSelection()?.removeAllRanges();
loop.start();
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const m = motion.current;
m.hovering = true;
if (e.pointerId !== m.pointerId) return;
if (m.pending && !m.dragging) {
if (Math.abs(e.clientX - m.downX) < DRAG_THRESHOLD) return;
m.dragging = true;
m.moved = true;
m.grabX = e.clientX;
m.grabAngle = m.angle;
m.lastX = e.clientX;
m.lastT = e.timeStamp;
e.currentTarget.setPointerCapture(e.pointerId);
containerRef.current?.style.setProperty("user-select", "none");
window.getSelection()?.removeAllRanges();
}
if (!m.dragging) return;
const l = live.current;
m.angle = m.grabAngle + (e.clientX - m.grabX) * l.dragSensitivity;
const dt = (e.timeStamp - m.lastT) / 1000;
if (dt > 0.001) {
m.velocity = ((e.clientX - m.lastX) * l.dragSensitivity) / dt;
m.lastX = e.clientX;
m.lastT = e.timeStamp;
}
loop.start();
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const m = motion.current;
if (m.pointerId !== -1 && e.currentTarget.hasPointerCapture(m.pointerId)) {
e.currentTarget.releasePointerCapture(m.pointerId);
}
m.pending = false;
m.dragging = false;
m.pointerId = -1;
containerRef.current?.style.removeProperty("user-select");
loop.start();
};
const onClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
const m = motion.current;
if (!m.moved) return;
m.moved = false;
e.preventDefault();
e.stopPropagation();
};
const onPointerLeave = () => {
motion.current.hovering = false;
loop.start();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = 360 / live.current.count;
if (e.key === "ArrowRight") {
motion.current.angle -= step;
} else if (e.key === "ArrowLeft") {
motion.current.angle += step;
} else if (e.key === "Home") {
motion.current.angle = 0;
} else {
return;
}
e.preventDefault();
motion.current.velocity = 0;
loop.start();
};
return (
<div
ref={containerRef}
className={
className ??
"relative h-full w-full touch-none overflow-hidden outline-none"
}
style={{ perspective: `${perspective}px` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onPointerLeave={onPointerLeave}
onClickCapture={onClickCapture}
onKeyDown={onKeyDown}
tabIndex={0}
role="group"
aria-label="Rotating panel carousel. Use the left and right arrow keys."
>
<div
className="absolute inset-0"
style={{ transformStyle: "preserve-3d" }}
>
{items.map((child, i) => (
<div
key={i}
ref={(el) => {
panelsRef.current[i] = el;
}}
className="absolute top-1/2 left-1/2"
style={{ transformStyle: "preserve-3d" }}
>
<div
ref={(el) => {
innersRef.current[i] = el;
}}
>
{child}
</div>
</div>
))}
</div>
</div>
);
},
);
Rotunda.displayName = "Rotunda";
export default Rotunda;