Overview
A way to explain a system or a cycle. Items sit on a ring around a central idea. Picking one turns the whole ring by the shortest path to bring it to the top while labels stay upright; its spoke draws in and a small pulse travels out along it. Below, a detail panel swaps in with a blur, alongside a rolling counter and previous and next buttons. With autoPlay it steps through on its own, showing a progress line, pausing while you hover or focus it, and stopping once you choose an item yourself.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useEffect, useId, useRef, useState } from "react";import type { KeyboardEvent, ReactNode } from "react";import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";import type { MotionValue } from "motion/react";import { cn } from "@/lib/utils"; export type OrbitItem = { id: string; label: string; detail: string; icon?: ReactNode;}; export type OrbitDiagramProps = { title: string; /** Small line under the title in the centre. */ subtitle?: string; items: OrbitItem[]; defaultIndex?: number; onChange?: (item: OrbitItem, index: number) => void; /** Milliseconds per item to advance on its own. Stops once someone picks an item. */ autoPlay?: number; /** Turn the ring so the active item sits at the top. */ rotate?: boolean; className?: string;}; const EASE_OUT = [0.23, 1, 0.32, 1] as const;const R = 38; // ring radius, % of the diagramconst TURN = { type: "spring" as const, stiffness: 70, damping: 16, mass: 1 }; function polar(index: number, count: number) { const a = (index / count) * Math.PI * 2 - Math.PI / 2; return { x: 50 + Math.cos(a) * R, y: 50 + Math.sin(a) * R };} export function OrbitDiagram({ title, subtitle, items, defaultIndex = 0, onChange, autoPlay, rotate = true, className,}: OrbitDiagramProps) { const reduce = !!useReducedMotion(); const uid = useId(); const [active, setActive] = useState(() => Math.min(Math.max(0, defaultIndex), Math.max(0, items.length - 1))); const [playing, setPlaying] = useState(!!autoPlay); const [hold, setHold] = useState(false); const refs = useRef<(HTMLButtonElement | null)[]>([]); const count = items.length; const current = Math.min(active, Math.max(0, count - 1)); // The ring turns by the shortest path to bring the active item to the top. const turn = useMotionValue(0); const counter = useTransform(turn, (v) => -v); useEffect(() => { if (!count) return; const target = rotate && !reduce ? -(current / count) * 360 : 0; const now = turn.get(); const delta = ((((target - now) % 360) + 540) % 360) - 180; if (reduce || !rotate) turn.jump(target); else { const c = animate(turn, now + delta, TURN); return () => c.stop(); } }, [current, count, rotate, reduce, turn]); if (!count) return null; const item = items[current]; const select = (index: number, manual: boolean) => { const next = ((index % count) + count) % count; if (manual) setPlaying(false); setActive(next); onChange?.(items[next], next); }; const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { const map: Record<string, number> = { ArrowRight: current + 1, ArrowDown: current + 1, ArrowLeft: current - 1, ArrowUp: current - 1, Home: 0, End: count - 1, }; if (!(e.key in map)) return; e.preventDefault(); const next = ((map[e.key] % count) + count) % count; select(next, true); refs.current[next]?.focus(); }; const running = playing && !!autoPlay && !hold && !reduce; return ( <section aria-label={`${title} diagram`} className={cn("w-full", className)} onPointerEnter={() => setHold(true)} onPointerLeave={() => setHold(false)} onFocus={() => setHold(true)} onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setHold(false)} > <style>{`@keyframes orbit-progress{from{transform:scaleX(0)}to{transform:scaleX(1)}}`}</style> <div className="relative mx-auto aspect-square w-full max-w-[26rem]"> {/* Static rings and glow */} <div aria-hidden className="absolute inset-[6%] rounded-full bg-[radial-gradient(closest-side,var(--color-paper-2),transparent)]" /> <svg aria-hidden viewBox="0 0 100 100" className="absolute inset-0 size-full overflow-visible"> <circle cx="50" cy="50" r={R + 7} fill="none" stroke="var(--color-rule)" strokeWidth="0.25" /> <motion.circle cx="50" cy="50" r={R} fill="none" stroke="var(--color-rule)" strokeWidth="0.4" strokeDasharray="0.6 2.2" strokeLinecap="round" animate={reduce ? undefined : { strokeDashoffset: [0, -28] }} transition={{ duration: 10, repeat: Infinity, ease: "linear" }} /> <circle cx="50" cy="50" r={R - 16} fill="none" stroke="var(--color-rule)" strokeWidth="0.25" strokeDasharray="0.4 1.6" /> </svg> {/* Turning layer: spokes and nodes */} <motion.div className="absolute inset-0" style={{ rotate: turn }}> <svg aria-hidden viewBox="0 0 100 100" className="absolute inset-0 size-full overflow-visible"> {items.map((it, i) => { const p = polar(i, count); const on = i === current; return ( <g key={it.id}> <motion.line x1="50" y1="50" x2={p.x} y2={p.y} stroke="var(--color-ink)" strokeWidth="0.45" strokeLinecap="round" initial={false} animate={{ pathLength: on ? 1 : 0, opacity: on ? 0.9 : 0 }} transition={reduce ? { duration: 0 } : { duration: 0.5, ease: EASE_OUT }} /> {on && !reduce ? ( <motion.circle key={`pulse-${current}`} r="0.9" fill="var(--color-ink)" initial={{ cx: 50, cy: 50, opacity: 0 }} animate={{ cx: [50, p.x], cy: [50, p.y], opacity: [0, 1, 1, 0] }} transition={{ duration: 1.4, repeat: Infinity, repeatDelay: 0.5, ease: "easeInOut", delay: 0.4 }} /> ) : null} </g> ); })} </svg> <div role="tablist" aria-label={title} aria-orientation="horizontal"> {items.map((it, i) => { const p = polar(i, count); return ( <OrbitNode key={it.id} item={it} on={i === current} x={p.x} y={p.y} counter={counter} reduce={reduce} id={`${uid}-tab-${i}`} panelId={`${uid}-panel`} buttonRef={(el) => { refs.current[i] = el; }} onSelect={() => select(i, true)} onKeyDown={onKeyDown} /> ); })} </div> </motion.div> {/* Centre */} <div className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> {!reduce ? ( <motion.span aria-hidden className="absolute inset-0 rounded-full bg-(--color-ink)" animate={{ scale: [1, 1.35], opacity: [0.12, 0] }} transition={{ duration: 2.6, repeat: Infinity, ease: "easeOut" }} /> ) : null} <div className="relative grid size-[6.5rem] place-items-center rounded-full bg-(--color-ink) px-3 text-center text-(--color-paper) shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]"> <div> <div className="text-[14px] leading-tight font-semibold tracking-tight">{title}</div> {subtitle ? <div className="mt-0.5 text-[10.5px] leading-tight opacity-60">{subtitle}</div> : null} </div> </div> </div> </div> {/* Detail */} <div className="mx-auto mt-6 max-w-md"> <div className="flex items-center justify-between gap-3"> <button type="button" aria-label="Previous" onClick={() => select(current - 1, true)} className="grid size-8 shrink-0 place-items-center rounded-full text-(--color-neutral) outline-none transition-[color,background-color,transform] hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus)" > <svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <path d="M10 3.5 5.5 8l4.5 4.5" /> </svg> </button> <div className="flex items-center gap-2 font-mono text-[11.5px] tabular-nums text-(--color-neutral)"> <span className="relative inline-grid overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={current} initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }} transition={{ duration: 0.3, ease: EASE_OUT }} className="text-(--color-ink)" > {String(current + 1).padStart(2, "0")} </motion.span> </AnimatePresence> </span> <span>/ {String(count).padStart(2, "0")}</span> {autoPlay && !reduce ? ( <button type="button" onClick={() => setPlaying((p) => !p)} aria-label={playing ? "Stop auto-advance" : "Start auto-advance"} aria-pressed={playing} className="ml-1 rounded-full px-2 py-0.5 font-sans text-[11px] text-(--color-neutral) outline-none transition-colors hover:bg-(--color-paper-2) hover:text-(--color-ink) focus-visible:ring-2 focus-visible:ring-(--color-focus)" > {playing ? "Auto" : "Play"} </button> ) : null} </div> <button type="button" aria-label="Next" onClick={() => select(current + 1, true)} className="grid size-8 shrink-0 place-items-center rounded-full text-(--color-neutral) outline-none transition-[color,background-color,transform] hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus)" > <svg aria-hidden viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <path d="M6 3.5 10.5 8 6 12.5" /> </svg> </button> </div> {/* Auto-advance progress; the bar's animation end moves to the next item. */} <div aria-hidden className="mx-auto mt-2 h-[2px] w-24 overflow-hidden rounded-full bg-(--color-rule)"> {autoPlay && playing && !reduce ? ( <div key={current} className="h-full origin-left rounded-full bg-(--color-ink)" style={{ animation: `orbit-progress ${autoPlay}ms linear forwards`, animationPlayState: running ? "running" : "paused", }} onAnimationEnd={() => select(current + 1, false)} /> ) : null} </div> <div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-tab-${current}`} aria-live="polite" className="relative mt-4 grid text-center"> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={item.id} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(6px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(6px)" }} transition={{ duration: 0.35, ease: EASE_OUT }} > <div className="font-(family-name:--font-display) text-[18px] font-semibold tracking-tight text-(--color-ink)">{item.label}</div> <div className="mx-auto mt-1.5 max-w-sm text-[14px] leading-relaxed text-pretty text-(--color-ink-2)">{item.detail}</div> </motion.div> </AnimatePresence> </div> </div> </section> );} function OrbitNode({ item, on, x, y, counter, reduce, id, panelId, buttonRef, onSelect, onKeyDown,}: { item: OrbitItem; on: boolean; x: number; y: number; counter: MotionValue<number>; reduce: boolean; id: string; panelId: string; buttonRef: (el: HTMLButtonElement | null) => void; onSelect: () => void; onKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => void;}) { return ( <motion.button ref={buttonRef} id={id} type="button" role="tab" aria-selected={on} aria-controls={panelId} tabIndex={on ? 0 : -1} onClick={onSelect} onKeyDown={onKeyDown} // Counter-rotate so labels stay upright while the ring turns. style={{ left: `${x}%`, top: `${y}%`, x: "-50%", y: "-50%", rotate: counter }} className="group absolute z-10 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)" > <motion.span initial={false} animate={{ scale: on ? 1.12 : 1 }} whileHover={reduce ? undefined : { scale: on ? 1.16 : 1.08 }} whileTap={reduce ? undefined : { scale: 0.94 }} transition={{ type: "spring", bounce: 0.4, duration: 0.45 }} className={cn( "relative grid size-11 place-items-center rounded-full transition-[background-color,color,box-shadow] duration-300 [&_svg]:size-[18px]", on ? "bg-(--color-ink) text-(--color-paper) shadow-[0_0_0_4px_var(--color-paper),0_0_0_5px_var(--color-ink),0_8px_20px_-8px_rgba(0,0,0,0.5)]" : "bg-(--color-paper) text-(--color-neutral) shadow-[0_0_0_1px_var(--color-rule),0_2px_6px_-3px_rgba(0,0,0,0.15)] group-hover:text-(--color-ink)", )} > {item.icon ?? <span aria-hidden className="size-2 rounded-full bg-current" />} </motion.span> <span className={cn( "absolute top-full left-1/2 mt-2 max-w-28 -translate-x-1/2 truncate rounded-full px-2 py-0.5 text-[12px] whitespace-nowrap transition-colors duration-300", on ? "bg-(--color-paper) font-semibold text-(--color-ink) shadow-[0_0_0_1px_var(--color-rule)]" : "text-(--color-neutral) group-hover:text-(--color-ink-2)", )} > {item.label} </span> </motion.button> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { OrbitDiagram } from "@/components/ui/orbit-diagram";import { Compass, ScanSearch, Sparkles } from "lucide-react"; <OrbitDiagram title="Design" subtitle="a loop, not a line" autoPlay={4000} items={[ { id: "observe", label: "Observe", detail: "Find the real friction.", icon: <ScanSearch /> }, { id: "frame", label: "Frame", detail: "Turn it into one clear question.", icon: <Compass /> }, { id: "ship", label: "Ship", detail: "Release, then learn.", icon: <Sparkles /> }, ]}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| title | string | Yes | — | Label in the centre. |
| items | { id, label, detail, icon? }[] | Yes | — | Items around the ring. Three to seven fit best. |
| subtitle | string | No | — | Small line under the title. |
| defaultIndex | number | No | 0 | Item selected first. |
| onChange | (item, index) => void | No | — | Fires when the selection changes. |
| autoPlay | number | No | — | Milliseconds per item to advance on its own. |
| rotate | boolean | No | true | Turn the ring so the selected item sits at the top. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Use it for cycles and hub-and-spoke ideas; for a sequence with a clear start and end, a step list reads better.
- Keep labels to one word so they don't crowd the ring.
- Items act as tabs: arrow keys move between them, and the detail panel is announced politely.
- Reduced motion keeps the ring still and swaps details with a plain fade.