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.
npx shadcn@latest add @paragon/circular-gallery"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>
);
}