Arc Carousel
A carousel that fans its cards across the top of a circle, the active card rests at the apex, crisp and upright, while its neighbours curve down each slope and recede.

A freestanding carousel that stages its cards on the top of a large circle instead of a flat strip. No panel frames it: the cards hang in open space over a soft contact shadow that grounds the fan. The active card rests at the apex, upright and crisp on the focal plane; its neighbours curve down each slope, rotating with the arc's tangent while they fall back, dim, and blur with depth. One spring drives every way of moving: arrows, keys, drag, and clicking a card all retarget the same fractional index, so a flick mid-glide keeps its momentum instead of restarting. On a fine pointer the whole dome leans a few degrees toward the cursor. Built with Motion.
Drag the arc and flick: a quick swipe advances regardless of distance. Click any card on a slope to rotate it up to the apex.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/arc-carousel.tsx.
'use client';import * as React from 'react';import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue,} from 'motion/react';import { ChevronLeft, ChevronRight } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const GLIDE = { stiffness: 170, damping: 26, mass: 1 } as const;const TILT = { stiffness: 110, damping: 16 } as const;const EASE_CSS = '[transition-timing-function:cubic-bezier(0.23,1,0.32,1)]';const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));const DEG = Math.PI / 180;export type ArcCarouselItem = { src: string; alt: string; label?: string;};export type ArcCarouselProps = { items: ArcCarouselItem[]; defaultIndex?: number; accent?: string; ratio?: string; spread?: number; radius?: number; label?: string; className?: string;};export function ArcCarousel({ items, defaultIndex = 0, accent = '#f0883e', ratio = '4 / 3', spread = 20, radius = 640, label = 'Arc carousel', className,}: ArcCarouselProps) { const reduce = useReducedMotion(); const count = items.length; const last = count - 1; const [active, setActive] = React.useState(() => clamp(defaultIndex, 0, Math.max(0, last)), ); const target = useMotionValue(active); const progress = useSpring(target, GLIDE); const tiltX = useSpring(0, TILT); const tiltY = useSpring(0, TILT); const sceneTransform = useMotionTemplate`rotateX(${tiltY}deg) rotateY(${tiltX}deg)`; const [finePointer, setFinePointer] = React.useState(false); React.useEffect(() => { const mq = window.matchMedia('(hover: hover) and (pointer: fine)'); const update = () => setFinePointer(mq.matches); update(); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, []); const stageRef = React.useRef<HTMLDivElement>(null); const drag = React.useRef<{ id: number; startX: number; base: number; lastX: number; lastT: number; velocity: number; moved: number; } | null>(null); const suppressClick = React.useRef(false); const goTo = React.useCallback( (i: number) => { const next = clamp(i, 0, last); setActive(next); target.set(next); }, [last, target], ); const onPointerDown = (e: React.PointerEvent) => { if (drag.current) return; if (e.pointerType === 'mouse' && e.button !== 0) return; drag.current = { id: e.pointerId, startX: e.clientX, base: target.get(), lastX: e.clientX, lastT: e.timeStamp, velocity: 0, moved: 0, }; suppressClick.current = false; stageRef.current?.setPointerCapture(e.pointerId); }; const onPointerMove = (e: React.PointerEvent) => { if (finePointer && !reduce && !drag.current) { const rect = stageRef.current?.getBoundingClientRect(); if (rect) { const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1; tiltX.set(nx * 5); tiltY.set(ny * -3); } } const d = drag.current; if (!d || e.pointerId !== d.id) return; const dx = e.clientX - d.startX; d.moved = Math.max(d.moved, Math.abs(dx)); if (d.moved > 6) suppressClick.current = true; const dt = e.timeStamp - d.lastT; if (dt > 0) d.velocity = (e.clientX - d.lastX) / dt; d.lastX = e.clientX; d.lastT = e.timeStamp; const step = (stageRef.current?.offsetWidth ?? 640) * 0.42; let raw = d.base - dx / step; if (raw < 0) raw *= 0.35; else if (raw > last) raw = last + (raw - last) * 0.35; target.jump(raw); }; const endDrag = (e: React.PointerEvent) => { const d = drag.current; if (!d || e.pointerId !== d.id) return; drag.current = null; stageRef.current?.releasePointerCapture(d.id); const current = target.get(); let next: number; if (Math.abs(d.velocity) > 0.35 && d.moved > 12) { next = d.velocity < 0 ? Math.ceil(current) : Math.floor(current); } else { next = Math.round(current); } goTo(next); }; const onPointerLeave = () => { tiltX.set(0); tiltY.set(0); }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowRight') { e.preventDefault(); goTo(active + 1); } else if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(active - 1); } else if (e.key === 'Home') { e.preventDefault(); goTo(0); } else if (e.key === 'End') { e.preventDefault(); goTo(last); } }; return ( <section aria-roledescription="carousel" aria-label={label} style={{ '--arc-accent': accent } as React.CSSProperties} className={cn( 'relative w-full max-w-[760px] text-neutral-900 dark:text-white', className, )} > <div ref={stageRef} role="group" tabIndex={0} aria-label={`Card ${active + 1} of ${count}`} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} className={cn( 'relative h-[360px] touch-pan-y select-none rounded-[18px]', 'cursor-grab active:cursor-grabbing', 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:[outline-color:var(--arc-accent)]', )} style={{ perspective: 1300 }} > <div aria-hidden className="pointer-events-none absolute top-[62%] left-1/2 h-14 w-[46%] -translate-x-1/2 rounded-[100%] bg-black/15 blur-2xl dark:bg-black/50" /> <motion.div style={{ transform: reduce ? undefined : sceneTransform, transformStyle: 'preserve-3d', }} className="absolute inset-0 [mask-image:linear-gradient(to_right,transparent,black_4%,black_96%,transparent)]" > {items.map((item, i) => ( <ArcCard key={i} item={item} index={i} progress={progress} ratio={ratio} spread={spread} radius={radius} active={i === active} reduce={!!reduce} onSelect={() => { if (!suppressClick.current) goTo(i); }} /> ))} </motion.div> </div> <div className="mt-1 flex items-center justify-center gap-5"> <ArcArrow dir="prev" disabled={active <= 0} onClick={() => goTo(active - 1)} /> <div aria-hidden className="flex items-baseline gap-1 text-[13px] font-medium tabular-nums tracking-[-0.01em]" > <span style={{ color: 'var(--arc-accent)' }}> {String(active + 1).padStart(2, '0')} </span> <span className="text-neutral-400 dark:text-neutral-600">/</span> <span className="text-neutral-400 dark:text-neutral-500"> {String(count).padStart(2, '0')} </span> </div> <ArcArrow dir="next" disabled={active >= last} onClick={() => goTo(active + 1)} /> </div> <span aria-live="polite" className="sr-only"> Card {active + 1} of {count}: {items[active]?.alt} </span> </section> );}function ArcArrow({ dir, disabled, onClick,}: { dir: 'prev' | 'next'; disabled: boolean; onClick: () => void;}) { const Chevron = dir === 'prev' ? ChevronLeft : ChevronRight; return ( <button type="button" aria-label={dir === 'prev' ? 'Previous card' : 'Next card'} disabled={disabled} onPointerDown={(e) => e.stopPropagation()} onClick={onClick} className={cn( 'bloom-edge group grid size-9 shrink-0 place-items-center rounded-full', 'bg-gradient-to-b from-white to-[#f1f1f2] dark:from-[#272727] dark:to-[#1c1c1c]', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.9),0_1px_2px_0_rgba(0,0,0,0.08),0_6px_16px_-8px_rgba(0,0,0,0.2)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.08),0_1px_2px_0_rgba(0,0,0,0.5),0_8px_20px_-10px_rgba(0,0,0,0.7)]', `transition-[transform,opacity] duration-150 ${EASE_CSS}`, 'hover:-translate-y-px active:scale-[0.94] active:translate-y-0', 'motion-reduce:hover:translate-y-0 motion-reduce:active:scale-100', 'disabled:pointer-events-none disabled:opacity-35', 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--arc-accent)]', )} > <Chevron size={17} strokeWidth={2} aria-hidden className={cn( 'text-neutral-600 dark:text-neutral-300', `transition-transform duration-200 ${EASE_CSS}`, dir === 'prev' ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5', 'motion-reduce:transition-none motion-reduce:group-hover:translate-x-0', )} /> </button> );}function ArcCard({ item, index, progress, ratio, spread, radius, active, reduce, onSelect,}: { item: ArcCarouselItem; index: number; progress: MotionValue<number>; ratio: string; spread: number; radius: number; active: boolean; reduce: boolean; onSelect: () => void;}) { const d = useTransform(progress, (v) => index - v); const transform = useTransform(d, (t) => { if (reduce) return 'translate(-50%, -50%)'; const c = clamp(t, -4, 4); const a = Math.abs(c); const phi = c * spread * DEG; const x = radius * Math.sin(phi); const y = radius * (1 - Math.cos(phi)) * 0.78; const z = -a * 130; const scale = 1 - Math.min(a * 0.04, 0.28); const rot = c * spread; return `translate(-50%, -50%) translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, ${z.toFixed(2)}px) rotateZ(${rot.toFixed(2)}deg) scale(${scale.toFixed(3)})`; }); const opacity = useTransform(d, (t) => { const a = Math.abs(t); if (reduce) return a < 0.5 ? 1 : 0; if (a <= 1) return 1; return Math.max(0, 1 - (a - 1) * 0.4); }); const filter = useTransform(d, (t) => reduce ? 'none' : `blur(${Math.min(Math.abs(t) * 1.3, 4.5).toFixed(2)}px)`, ); const zIndex = useTransform(d, (t) => 100 - Math.round(Math.abs(t) * 10)); const pointerEvents = useTransform(d, (t) => Math.abs(t) > 3.2 ? ('none' as const) : ('auto' as const), ); return ( <motion.div aria-hidden={!active} style={{ transform, opacity, filter, zIndex, pointerEvents, aspectRatio: ratio, }} onClick={onSelect} className={cn( 'absolute top-[38%] left-1/2 w-[min(42%,270px)] overflow-hidden rounded-[13px]', 'bloom-edge bg-neutral-200 dark:bg-[#242424]', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.08),0_16px_34px_-14px_rgba(0,0,0,0.28)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.07),0_1px_2px_0_rgba(0,0,0,0.4),0_20px_48px_-14px_rgba(0,0,0,0.72)]', !active && 'cursor-pointer', )} > <img src={item.src} alt={active ? item.alt : ''} draggable={false} decoding="async" className="pointer-events-none block size-full object-cover" /> <span aria-hidden className="pointer-events-none absolute inset-0 rounded-[13px] bg-gradient-to-b from-white/12 to-transparent to-35% dark:from-white/[0.08]" /> {item.label && ( <div aria-hidden className={cn( 'pointer-events-none absolute inset-x-0 bottom-0 flex items-end p-3 pt-10', 'bg-gradient-to-t from-black/60 via-black/15 to-transparent', `transition-opacity duration-300 ${EASE_CSS}`, active ? 'opacity-100' : 'opacity-0', )} > <span className="truncate text-[12.5px] font-medium tracking-[-0.01em] text-white drop-shadow-[0_1px_3px_rgba(0,0,0,0.6)]"> {item.label} </span> </div> )} <span aria-hidden className={cn( 'pointer-events-none absolute inset-0 rounded-[13px] bg-black/20', `transition-opacity duration-300 ${EASE_CSS}`, active ? 'opacity-0' : 'opacity-100', )} /> </motion.div> );}Usage
import { ArcCarousel } from '@/components/ui/arc-carousel';
export default function Example() {
return (
<ArcCarousel
items={[
{ src: '/media/one.jpg', alt: 'Dunes at dusk', label: 'Evening Dunes' },
{ src: '/media/two.jpg', alt: 'Coastline from above', label: 'Coastline' },
{ src: '/media/three.jpg', alt: 'Ridges at dawn', label: 'Ridgelines' },
]}
/>
);
}Examples
spread sets the degrees between neighbouring cards and radius the
curvature: a larger radius with a smaller spread flattens the dome into a
gentle rise.

Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | { src: string; alt: string; label?: string }[] | - | The cards. |
defaultIndex | number | 0 | Card at the apex first. |
accent | string | "#f0883e" | Focus ring and counter tint. |
ratio | string | "4 / 3" | Aspect ratio of each card. |
spread | number | 20 | Degrees between neighbouring cards along the arc. |
radius | number | 640 | Radius of the arc in px; larger flattens the dome. |
label | string | "Arc carousel" | Accessible name for the carousel region. |
className | string | - | Forwarded to the outer panel. |
Drift Slider
A media slider staged in 3D space, the active image glides to the focal plane while its neighbours scatter back, dimming and blurring with depth.
Browser Mockup
A macOS-style browser window for landing page heroes, the URL types itself, a loading bar sweeps, and your content unblurs into focus.



