Galleries & Media

Circular Gallery

Items ride a wheel you drag to spin with momentum; each scales and brightens as it nears the active slot, snapping to the closest item.

Install

npx shadcn@latest add @paragon/circular-gallery

circular-gallery.tsx

"use client";

import * as React from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * CircularGallery — items ride a vertical wheel you drag to spin, with inertia.
 * Each item's angle is its base slot plus a shared `rotation` motion value;
 * positions are computed with trig on a circle whose front arc faces the
 * viewer, so items scale and brighten as they approach the top (the active
 * slot) and recede toward the back. Releasing a drag throws the wheel with a
 * momentum decay that snaps to the nearest item.
 *
 * Pass `items`; otherwise deterministic gradient chips render. Drag / arrow
 * keys rotate it. Reduced motion snaps instantly (no momentum spin).
 */
export interface CircularGalleryProps
  extends Omit<React.ComponentProps<"div">, "children" | "onChange"> {
  items?: React.ReactNode[];
  /** Number of generated items when `items` is omitted. */
  count?: number;
  /** Wheel radius, px. */
  radius?: number;
  /** Momentum multiplier applied to release velocity (higher = spins longer). */
  momentum?: number;
  /** Item chip size, px. */
  size?: number;
  onActiveChange?: (index: number) => void;
}

const CHIP_GRADIENTS = [
  "linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.5 0.2 285))",
  "linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.55 0.16 200))",
  "linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.64 0.19 38))",
  "linear-gradient(135deg, oklch(0.75 0.17 18), oklch(0.55 0.18 348))",
  "linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.52 0.16 262))",
  "linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.6 0.14 236))",
  "linear-gradient(135deg, oklch(0.8 0.15 128), oklch(0.6 0.17 160))",
  "linear-gradient(135deg, oklch(0.84 0.16 92), oklch(0.66 0.18 52))",
  "linear-gradient(135deg, oklch(0.74 0.15 330), oklch(0.55 0.18 300))",
];

function GeneratedChip({ i }: { i: number }) {
  return (
    <div
      className="flex size-full items-center justify-center rounded-2xl"
      style={{ background: CHIP_GRADIENTS[i % CHIP_GRADIENTS.length] }}
    >
      <span className="text-base font-semibold tabular-nums text-white drop-shadow-[0_1px_3px_rgba(0,0,0,0.4)]">
        {String(i + 1).padStart(2, "0")}
      </span>
    </div>
  );
}

export function CircularGallery({
  items,
  count = 9,
  radius = 150,
  momentum = 1,
  size = 76,
  onActiveChange,
  className,
  style,
  ...props
}: CircularGalleryProps) {
  const reduced = useReducedMotion() ?? false;

  const chips = React.useMemo<React.ReactNode[]>(() => {
    if (items && items.length) return items;
    return Array.from({ length: count }, (_, i) => <GeneratedChip key={i} i={i} />);
  }, [items, count]);

  const n = chips.length;
  const step = 360 / n;

  // Shared rotation in degrees. Positive = wheel turns forward.
  const rotation = useMotionValue(0);
  const dragBase = React.useRef(0);
  const [active, setActive] = React.useState(0);

  const reportActive = React.useCallback(
    (rot: number) => {
      // Active slot is the one nearest the top (angle 0 after rotation).
      const idx = ((Math.round(-rot / step) % n) + n) % n;
      setActive(idx);
      onActiveChange?.(idx);
    },
    [n, step, onActiveChange],
  );

  React.useEffect(() => {
    const unsub = rotation.on("change", reportActive);
    return unsub;
  }, [rotation, reportActive]);

  const snapTo = React.useCallback(
    (target: number) => {
      const snapped = Math.round(target / step) * step;
      if (reduced) {
        rotation.set(snapped);
      } else {
        animate(rotation, snapped, {
          type: "spring",
          stiffness: 120,
          damping: 20,
        });
      }
    },
    [rotation, step, reduced],
  );

  const goTo = React.useCallback(
    (idx: number) => {
      const target = -idx * step;
      snapTo(target);
    },
    [snapTo, step],
  );

  return (
    <div
      role="listbox"
      aria-label="Circular gallery"
      tabIndex={0}
      onKeyDown={(e) => {
        if (e.key === "ArrowUp" || e.key === "ArrowRight") {
          e.preventDefault();
          goTo((active + 1) % n);
        } else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
          e.preventDefault();
          goTo((active - 1 + n) % n);
        }
      }}
      className={cn(
        "relative touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        className,
      )}
      style={{
        width: radius * 2 + size,
        height: radius * 2 + size,
        margin: "0 auto",
        ...style,
      }}
      {...props}
    >
      {/* Active-slot marker at the top. */}
      <div
        aria-hidden
        className="pointer-events-none absolute left-1/2 top-0 -translate-x-1/2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
      >
        Active
      </div>

      {/* Drag surface. */}
      <motion.div
        className="absolute inset-0 z-30 cursor-grab rounded-full active:cursor-grabbing"
        drag="y"
        dragConstraints={{ top: 0, bottom: 0 }}
        dragElastic={0}
        dragMomentum={false}
        onDragStart={() => {
          dragBase.current = rotation.get();
        }}
        onDrag={(_, info) => {
          // Vertical drag on the right half rotates the wheel.
          rotation.set(dragBase.current + info.offset.y * 0.4);
        }}
        onDragEnd={(_, info) => {
          const throwTo =
            rotation.get() + (reduced ? 0 : info.velocity.y * 0.08 * momentum);
          snapTo(throwTo);
        }}
      />

      <div className="absolute inset-0">
        {chips.map((chip, i) => (
          <WheelItem
            key={i}
            index={i}
            active={i === active}
            baseAngle={i * step}
            rotation={rotation}
            radius={radius}
            size={size}
            onClick={() => goTo(i)}
          >
            {chip}
          </WheelItem>
        ))}
      </div>
    </div>
  );
}

function WheelItem({
  index,
  active,
  baseAngle,
  rotation,
  radius,
  size,
  onClick,
  children,
}: {
  index: number;
  active: boolean;
  baseAngle: number;
  rotation: ReturnType<typeof useMotionValue<number>>;
  radius: number;
  size: number;
  onClick: () => void;
  children: React.ReactNode;
}) {
  // Angle measured from the top (active slot), going clockwise.
  const rad = useTransform(rotation, (r) => ((baseAngle + r - 90) * Math.PI) / 180);
  const x = useTransform(rad, (a) => Math.cos(a) * radius);
  const y = useTransform(rad, (a) => Math.sin(a) * radius);
  // Proximity to the top: 1 at the active slot, 0 at the bottom.
  const near = useTransform(rotation, (r) => {
    const t = (((baseAngle + r) % 360) + 360) % 360; // 0 at top
    const d = Math.min(t, 360 - t) / 180; // 0..1 (0 = top)
    return 1 - d;
  });
  const scale = useTransform(near, [0, 1], [0.62, 1]);
  const opacity = useTransform(near, [0, 1], [0.4, 1]);
  const zIndex = useTransform(near, (v) => Math.round(v * 100));

  return (
    <motion.button
      type="button"
      role="option"
      aria-selected={active}
      aria-label={`Item ${index + 1}`}
      onClick={onClick}
      className="absolute left-1/2 top-1/2 will-change-transform"
      style={{
        width: size,
        height: size,
        x,
        y,
        translateX: "-50%",
        translateY: "-50%",
        scale,
        opacity,
        zIndex,
      }}
    >
      <div className="size-full overflow-hidden rounded-2xl shadow-overlay">
        {children}
      </div>
    </motion.button>
  );
}