Cycle Ring Viz
Cycle nodes laid out around a ring with directed clockwise edges and animated pointer dots that glide along the perimeter. Designed for cycle-detection narratives — Floyd's tortoise/hare, Brent's algorithm, hash-chain cycle hunting — where the visual point is motion around the cycle, not the leading ρ-tail.
CycleRingViz is a pure layout primitive. The component does not run the algorithm; it places nodes on a circle and animates pointer dots to whatever positions the caller (or its internal autoplay timer) requests.
Customize
Ring
6
84
Pointers
Installation
npx shadcn@latest add https://craftbits.dev/r/cycle-ring-viz.jsonUsage
import { CycleRingViz, type CycleRingPointer } from "@craft-bits/core";
const pointers: CycleRingPointer[] = [
{ id: "slow", position: 0, label: "S" },
{ id: "fast", position: 0, label: "F" },
];
<CycleRingViz cycleLength={6} pointers={pointers} />;Uncontrolled — let the component own pointer state and autoplay them around the ring:
<CycleRingViz
cycleLength={8}
defaultPointers={[{ id: "walker", position: 0, label: "i" }]}
playing
playSpeed={500}
/>Driven by a real tortoise/hare loop from outside:
const [slow, setSlow] = useState(0);
const [fast, setFast] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setSlow((s) => (s + 1) % 6);
setFast((f) => (f + 2) % 6);
}, 700);
return () => window.clearInterval(id);
}, []);
<CycleRingViz
cycleLength={6}
pointers={[
{ id: "slow", position: slow, label: "S" },
{ id: "fast", position: fast, label: "F" },
]}
/>;Understanding the component
- Uniform polar layout. Nodes are placed at angles starting from
-PI/2(twelve o'clock) and stepping clockwise by2*PI / cycleLength. The ring radius defaults to a value scaled from the node count so small rings stay readable and large rings don't crowd. - Directed edges via
arrowEndpoint. Edges between adjacent ring nodes are shortened to stop at the node boundary using the sharedarrowEndpointhelper, so arrowheads never collide with node circles. Marker geometry is pulled from the sharedSvgDefsblock. - Pointer dots orbit the ring. Each
CycleRingPointercarries a stableid, a cycle-nodeposition, and a shortlabel. The dot is drawn just outside the ring along the same angle as its target node — far enough to never overlap the node, close enough to read as "at" it. - Glide via
layoutId. Each pointer's group carries a stablelayoutId. Whenpositionchanges, motion handles the cross-fade and the glide alongSPRINGS.smooth(or yourtransitionoverride). - Controlled + uncontrolled. Pass
pointers+onPointerStepfor controlled mode (the Radix pattern). PassdefaultPointersto let the component own its pointer set internally. The autoplay timer reads from a ref so it never re-subscribes mid-flight. - Overlay slot. Anything you pass to
overlayrenders inside the SVG above the edges and beneath the pointer dots — typically aGapArchighlighting the chord between two pointers. - Reduced motion.
usePrefersReducedMotion()short-circuits every transition to instant. Node entries, edge draws, and pointer glides snap; autoplay never starts.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
cycleLength | number | required | Number of nodes around the ring. Clamped to at least 2. |
nodeLabels | readonly string[] | — | Optional per-node labels; missing entries fall back to numeric index. |
pointers | readonly CycleRingPointer[] | — | Controlled pointer set. Pair with onPointerStep. |
defaultPointers | readonly CycleRingPointer[] | — | Uncontrolled initial pointer set. |
onPointerStep | (id: string, next: number) => void | — | Fires on every autoplay tick with the next wrapped position. |
ringRadius | number | scaled from cycleLength | Ring radius in px. |
playing | boolean | false | Autoplay every pointer by one each tick. Off under reduced motion. |
playSpeed | number | 600 | Milliseconds between autoplay ticks (floored at 80 ms). |
overlay | ReactNode | — | SVG content rendered above edges, beneath pointers. |
transition | Transition | SPRINGS.smooth | Override the pointer-glide / edge-draw spring. |
className | string | — | Merged onto the outer SVG element. |
Accessibility
- The outer SVG is
role="img"with anaria-labelsummarising the ring shape and each pointer's current position (for example, "Cycle ring with 6 nodes. S at node 2, F at node 4."). - Every node renders its label as a child text element, so position is never communicated through colour alone.
- Motion respects
prefers-reduced-motion: pointer glides, node entries, and edge draws all collapse to instant; autoplay is suppressed entirely.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/viz/CycleRingViz.tsx). The original was scoped to a specific lesson — it pulled atrackHexaccent, hard-wired pointer colours from a tone palette, and rendered HTML chrome around the SVG. The library extract drops the lesson chrome, generalises pointers to a stable identifier-keyed shape, adds controlled / uncontrolled state on the Radix pattern, switches to the sharedSvgDefsmarker block, and routes node and edge dimensions throughSVG_TOKENSso it sits next toDAGRendererwithout visual drift.