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 motion

Add 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:

components/ui/orbit-diagram.tsx
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

PropTypeRequiredDefaultDescription
titlestringYes—Label in the centre.
items{ id, label, detail, icon? }[]Yes—Items around the ring. Three to seven fit best.
subtitlestringNo—Small line under the title.
defaultIndexnumberNo0Item selected first.
onChange(item, index) => voidNo—Fires when the selection changes.
autoPlaynumberNo—Milliseconds per item to advance on its own.
rotatebooleanNotrueTurn the ring so the selected item sits at the top.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Use it for cycles and hub-and-spoke ideas; for a sequence with a clear start and end, a step list reads better.
  2. Keep labels to one word so they don't crowd the ring.
  3. Items act as tabs: arrow keys move between them, and the detail panel is announced politely.
  4. Reduced motion keeps the ring still and swaps details with a plain fade.