Galleries & Media

Spin Gallery

Tiles orbit a tilted 3D ellipse ring, depth-scaled and depth-sorted, in a continuous slow spin computed with trig.

Install

npx shadcn@latest add @paragon/spin-gallery

spin-gallery.tsx

"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>
  );
}