Tiles orbit a tilted 3D ellipse ring, depth-scaled and depth-sorted, in a continuous slow spin computed with trig.
npx shadcn@latest add @paragon/spin-gallery"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* SpinGallery — tiles orbit a tilted 3D ellipse ring in real perspective. Each
* tile's angle around the ring is advanced by a slow continuous spin; its
* screen position is computed with trig, then depth-scaled (nearer = bigger,
* brighter) and depth-sorted via z-index so nothing overlaps incorrectly. The
* ring is tilted by rotating the whole plane on X, and the tiles counter-rotate
* to always face the viewer (billboarding).
*
* Pass your own tiles via `items`; otherwise a deterministic set of generated
* gradient tiles renders. Reduced motion freezes the ring in a static, still-
* legible arrangement.
*/
export interface SpinGalleryProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Tiles to orbit. Falls back to generated gradient tiles. */
items?: React.ReactNode[];
/** Number of generated tiles when `items` is omitted. */
count?: number;
/** Seconds for one full revolution. */
speed?: number;
/** Ring tilt in degrees (rotateX of the orbit plane). */
tilt?: number;
/** Horizontal ring radius in px. */
radius?: number;
/** Vertical squash of the ellipse (0–1). Lower = flatter ring. */
yCurve?: number;
/** Perspective depth in px. */
perspective?: number;
/** Tile width in px (height derives from a 3:4 ratio). */
tileSize?: number;
}
const TILE_GRADIENTS = [
"linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.55 0.2 285))",
"linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.6 0.16 200))",
"linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.68 0.19 40))",
"linear-gradient(135deg, oklch(0.75 0.17 20), oklch(0.58 0.18 350))",
"linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.55 0.16 265))",
"linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.62 0.14 235))",
"linear-gradient(135deg, oklch(0.8 0.15 130), oklch(0.62 0.17 160))",
"linear-gradient(135deg, oklch(0.83 0.16 90), oklch(0.66 0.18 55))",
];
function GeneratedTile({ i }: { i: number }) {
return (
<div
className="flex size-full items-end justify-start rounded-2xl p-3"
style={{ background: TILE_GRADIENTS[i % TILE_GRADIENTS.length] }}
>
<span className="text-lg font-semibold tabular-nums text-white/95 drop-shadow-[0_1px_3px_rgba(0,0,0,0.35)]">
{String(i + 1).padStart(2, "0")}
</span>
</div>
);
}
export function SpinGallery({
items,
count = 8,
speed = 24,
tilt = 16,
radius = 190,
yCurve = 0.42,
perspective = 900,
tileSize = 128,
className,
style,
...props
}: SpinGalleryProps) {
const reduced = useReducedMotion() ?? false;
const [angle, setAngle] = React.useState(0);
const hostRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(true);
const tiles = React.useMemo<React.ReactNode[]>(() => {
if (items && items.length) return items;
return Array.from({ length: count }, (_, i) => (
<GeneratedTile key={i} i={i} />
));
}, [items, count]);
const n = tiles.length;
// Pause the spin when scrolled offscreen.
React.useEffect(() => {
const el = hostRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setActive(entry.isIntersecting),
{ threshold: 0.05 },
);
io.observe(el);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (reduced || !active || speed <= 0) return;
let raf = 0;
let prev = performance.now();
const degPerMs = 360 / (speed * 1000);
const loop = (now: number) => {
const dt = now - prev;
prev = now;
setAngle((a) => (a + dt * degPerMs) % 360);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [reduced, active, speed]);
const tileH = Math.round(tileSize * 1.32);
const placed = tiles.map((tile, i) => {
const theta = ((i / n) * 360 + angle) * (Math.PI / 180);
const x = Math.sin(theta) * radius;
// Depth in [-1, 1]; +1 = front (nearest).
const depth = Math.cos(theta);
const y = -depth * radius * yCurve;
const scale = 0.62 + (depth + 1) * 0.19; // 0.62 (back) → 1.0 (front)
const opacity = 0.5 + (depth + 1) * 0.25; // 0.5 → 1
const z = Math.round((depth + 1) * 500);
return { tile, i, x, y, scale, opacity, z };
});
return (
<div
ref={hostRef}
className={cn("relative flex items-center justify-center", className)}
style={{
height: radius * 2 * yCurve + tileH + 40,
perspective: `${perspective}px`,
...style,
}}
{...props}
>
<div
className="relative"
style={{
transformStyle: "preserve-3d",
transform: `rotateX(${tilt}deg)`,
}}
>
{placed.map(({ tile, i, x, y, scale, opacity, z }) => (
<div
key={i}
aria-hidden={false}
className="absolute left-1/2 top-1/2 will-change-transform"
style={{
width: tileSize,
height: tileH,
marginLeft: -tileSize / 2,
marginTop: -tileH / 2,
zIndex: z,
opacity,
transform: `translate3d(${x}px, ${y}px, 0) rotateX(${-tilt}deg) scale(${scale})`,
transformStyle: "preserve-3d",
transition: reduced ? undefined : "opacity 200ms var(--ease-out)",
}}
>
<div className="size-full overflow-hidden rounded-2xl shadow-overlay [backface-visibility:hidden]">
{tile}
</div>
</div>
))}
</div>
</div>
);
}