Ring Stack
NewInstallation
npx shadcn@latest add https://uibrik.com/r/ring-stack.jsonInstall the packages
npm install @number-flow/react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/ring-stack/ring-stack.tsx"use client"; import { useEffect, useRef, useState } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react"; import NumberFlow from "@number-flow/react"; import { cn } from "@/lib/utils"; // ─── Geometry ───────────────────────────────────────────────────────────────── const SIZE = 240; const C = SIZE / 2; const BAR_W = 196; const BAR_Y = C + 18; const SAMPLES = 120; // Ease-in-out: the shape is on screen the whole time, so it should leave and // arrive gently rather than snap most of the way and then crawl. const MORPH_EASE: [number, number, number, number] = [0.45, 0, 0.2, 1]; export type RingItem = { id: string; label: string; spent: number; budget: number }; const DEFAULT_ITEMS: RingItem[] = [ { id: "rent", label: "Rent", spent: 1800, budget: 1800 }, { id: "food", label: "Food", spent: 412, budget: 600 }, { id: "travel", label: "Travel", spent: 140, budget: 400 }, { id: "fun", label: "Fun", spent: 236, budget: 250 }, ]; // Rings are told apart by strength rather than hue, so the accent stays free // to mark the one in focus. const STRENGTH = [1, 0.72, 0.5, 0.34, 0.24]; const easeOut = (x: number) => 1 - (1 - x) ** 3; // ─── Unroll ─────────────────────────────────────────────────────────────────── // Every frame is a single clean arc: it starts heading right (as the ring does // at 12 o'clock and the bar does at its left end), and only its curvature and // length change. Curvature runs from 1/r to 0, so the ring uncoils into the bar // without ever kinking or cutting across itself. Length shrinks ahead of the // uncoiling so the half-open arc stays inside the frame, and the arc's centre // of mass glides from the ring's centre to the bar's, keeping it in place. /** Point at distance `s` along an arc that starts at the origin heading right with curvature `k`. */ function arcPoint(s: number, k: number): [number, number] { const a = k * s; // Near zero curvature sin(a)/k loses precision, so use the series instead. if (Math.abs(a) < 1e-4) return [s, (a * s) / 2]; return [Math.sin(a) / k, (1 - Math.cos(a)) / k]; } /** The arc for a ring of radius `r`, `t` of the way to the bar: its length, curvature and offset. */ function arc(r: number, t: number) { const length = 2 * Math.PI * r + (BAR_W - 2 * Math.PI * r) * easeOut(t); const k = (2 * Math.PI * (1 - t)) / length; // Sampled at segment midpoints, so a closed ring's centroid is exactly its centre. let cx = 0; let cy = 0; for (let i = 0; i < SAMPLES; i++) { const [x, y] = arcPoint(((i + 0.5) / SAMPLES) * length, k); cx += x; cy += y; } return { length, k, dx: C - cx / SAMPLES, dy: C + (BAR_Y - C) * t - cy / SAMPLES }; } /** SVG path for the first `fraction` of the arc. */ function path(r: number, t: number, fraction: number) { if (fraction <= 0.001) return ""; const { length, k, dx, dy } = arc(r, Math.min(1, Math.max(0, t))); const n = Math.max(2, Math.ceil(SAMPLES * fraction)); let d = ""; for (let i = 0; i <= n; i++) { const [x, y] = arcPoint((i / n) * fraction * length, k); d += `${i ? "L" : "M"}${(x + dx).toFixed(2)} ${(y + dy).toFixed(2)}`; } return d; } export type RingStackProps = { title?: string; items?: RingItem[]; /** Colour of the ring in focus. */ accent?: string; /** Currency symbol for amounts. */ currency?: string; /** Thickness of each ring in px. */ strokeWidth?: number; /** Distance between ring centres in px. */ ringGap?: number; /** Seconds a ring takes to unroll into its bar. */ morphDuration?: number; /** Caption under the overall percentage. */ totalLabel?: string; className?: string; }; export function RingStack({ title = "October budget", items = DEFAULT_ITEMS, accent = "#3b82f6", currency = "$", strokeWidth = 12, ringGap = 20, morphDuration = 0.7, totalLabel = "spent", className, }: RingStackProps) { const morph = { duration: morphDuration, ease: MORPH_EASE }; const outerR = C - strokeWidth; const [hovered, setHovered] = useState<string | null>(null); const [open, setOpen] = useState<string | null>(null); const focus = open ?? hovered; const rings = items.slice(0, STRENGTH.length); const totalSpent = rings.reduce((n, i) => n + i.spent, 0); const totalBudget = rings.reduce((n, i) => n + i.budget, 0); const focused = rings.find((i) => i.id === focus); const opened = rings.find((i) => i.id === open); const share = (i: { spent: number; budget: number }) => Math.min(1, i.budget ? i.spent / i.budget : 0); const pct = (i: { spent: number; budget: number }) => (i.budget ? Math.round((i.spent / i.budget) * 100) : 0); const money = (n: number) => `${currency}${n.toLocaleString("en-US")}`; // While a bar rolls back into its ring the others stay hidden, so it never // sweeps through them; they fade back in once it has landed. const [returning, setReturning] = useState<string | null>(null); // Picking another budget while one is open swaps bars instead of rolling one // up and the other out: every ring flattens to the same bar, so the new one // takes the old one's place in the same frame and only the fill slides. const [swap, setSwap] = useState<{ from: string; fill: number } | null>(null); const timer = useRef<ReturnType<typeof setTimeout>>(undefined); useEffect(() => () => clearTimeout(timer.current), []); const toggle = (id: string) => { clearTimeout(timer.current); const current = rings.find((i) => i.id === open); if (open === id) { setOpen(null); setSwap(null); setReturning(id); timer.current = setTimeout(() => setReturning(null), morphDuration * 800); } else { setSwap(current ? { from: current.id, fill: share(current) } : null); setOpen(id); setReturning(null); } }; const solo = open ?? returning; return ( <div className={cn("w-full max-w-75 rounded-[22px] bg-muted p-1.5 edge-outer", className)}> <div className="px-3.5 pb-2.5 pt-2 text-sm font-medium text-muted-foreground">{title}</div> <div className="rounded-2xl bg-background edge-inner"> <div className="relative mx-auto" style={{ width: SIZE, height: SIZE }}> <svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} className="block text-foreground" onPointerLeave={() => setHovered(null)} aria-hidden > {rings.map((item, i) => ( <Ring key={item.id} r={outerR - i * ringGap} stroke={strokeWidth} hitWidth={ringGap} morph={morph} value={share(item)} strength={STRENGTH[i]} accent={accent} focused={focus === item.id} dimmed={focus !== null && focus !== item.id} hidden={solo !== null && solo !== item.id} unrolled={open === item.id} swapIn={open === item.id && swap ? swap.fill : undefined} swapOut={swap?.from === item.id} onHover={() => setHovered(item.id)} onClick={() => toggle(item.id)} /> ))} </svg> {/* Centre: the focused ring's share, or the total. */} <AnimatePresence initial={false}> {!solo && ( <motion.div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center" initial={{ opacity: 0, filter: "blur(4px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(4px)" }} transition={{ duration: 0.2 }} > <NumberFlow value={focused ? pct(focused) : pct({ spent: totalSpent, budget: totalBudget })} suffix="%" className="text-2xl font-semibold tabular-nums tracking-tight text-foreground" /> <span className="text-xs font-medium text-muted-foreground">{focused ? focused.label : totalLabel}</span> </motion.div> )} </AnimatePresence> {/* Labels for the unrolled bar. They stay mounted across a swap; only the text changes. */} <AnimatePresence> {opened && ( <motion.div key="bar" className="pointer-events-none absolute flex items-end justify-between" style={{ left: C - BAR_W / 2, width: BAR_W, bottom: SIZE - BAR_Y + 18 }} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0, transition: { delay: morphDuration * 0.6, duration: 0.25 } }} exit={{ opacity: 0, y: 6, transition: { duration: 0.1 } }} > <div> <Swap id={opened.id} className="text-xs font-medium text-muted-foreground"> {opened.label} </Swap> <NumberFlow value={pct(opened)} suffix="%" className="text-2xl font-semibold tabular-nums tracking-tight text-foreground" /> </div> <Swap id={opened.id} className="pb-1 text-right text-xs text-muted-foreground"> <span className="font-medium text-foreground">{money(opened.spent)}</span> of {money(opened.budget)} <div>{opened.spent >= opened.budget ? "Budget used" : `${money(opened.budget - opened.spent)} left`}</div> </Swap> </motion.div> )} </AnimatePresence> </div> </div> <ul className="px-1 pb-0.5 pt-1.5" onPointerLeave={() => setHovered(null)}> {rings.map((item, i) => ( <li key={item.id}> <button type="button" aria-pressed={open === item.id} onPointerEnter={() => setHovered(item.id)} onFocus={() => setHovered(item.id)} onBlur={() => setHovered(null)} onClick={() => toggle(item.id)} className={cn( "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-xl px-2.5 text-left text-[13px] outline-none transition-[background-color,opacity] duration-150 focus-visible:ring-2 focus-visible:ring-foreground/20", focus === item.id ? "bg-foreground/4" : "hover:bg-foreground/3", // While another ring is open, a hovered row still lifts out of the dimming. focus !== null && focus !== item.id && "opacity-50 hover:opacity-80", )} > <span className="size-2 rounded-full transition-colors duration-200" style={{ background: focus === item.id ? accent : `color-mix(in srgb, var(--foreground) ${STRENGTH[i] * 100}%, transparent)`, }} /> <span className="flex-1 font-medium text-foreground">{item.label}</span> <span className="tabular-nums text-muted-foreground"> {money(item.spent)} <span className="opacity-60">/ {money(item.budget)}</span> </span> </button> </li> ))} </ul> </div> ); } /** Text that crossfades in place when `id` changes. */ function Swap({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) { return ( <div className="grid"> <AnimatePresence initial={false}> <motion.div key={id} className={cn("col-start-1 row-start-1", className)} initial={{ opacity: 0, filter: "blur(2px)" }} animate={{ opacity: 1, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(2px)" }} transition={{ duration: 0.18 }} > {children} </motion.div> </AnimatePresence> </div> ); } function Ring({ r, stroke, hitWidth, morph, value, strength, accent, focused, dimmed, hidden, unrolled, swapIn, swapOut, onHover, onClick, }: { r: number; stroke: number; /** Width of the invisible hit stroke; the gap between rings, so neighbours never overlap. */ hitWidth: number; morph: { duration: number; ease: [number, number, number, number] }; value: number; strength: number; accent: string; focused: boolean; dimmed: boolean; hidden: boolean; unrolled: boolean; /** Arrive already flat, with the fill starting at this fraction (the bar it replaces). */ swapIn?: number; /** Leave by vanishing in place, as the bar replacing it lands on top. */ swapOut: boolean; onHover: () => void; onClick: () => void; }) { const t = useMotionValue(0); const fill = useMotionValue(0); useEffect(() => { if (unrolled && swapIn !== undefined) { t.set(1); fill.set(swapIn); const a = animate(fill, value, { duration: 0.45, ease: [0.23, 1, 0.32, 1] }); return () => a.stop(); } // Hidden by now; roll back up out of sight so it's a ring when it returns. const a = animate(t, unrolled ? 1 : 0, swapOut ? { duration: 0, delay: 0.05 } : morph); return () => a.stop(); // `value` is read only to seed the swap; later changes go through the effect below. // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, fill, unrolled, swapIn, swapOut]); // Draws in on mount and follows the value after that. useEffect(() => { const a = animate(fill, value, { type: "spring", bounce: 0, duration: 0.9 }); return () => a.stop(); }, [fill, value]); const track = useTransform(t, (t) => path(r, t, 1)); const bar = useTransform([t, fill] as MotionValue<number>[], ([t, f]: number[]) => path(r, t, f)); return ( <motion.g initial={false} animate={{ opacity: hidden ? 0 : dimmed ? 0.25 : 1 }} // A swap is a hand-off between identical shapes: cut, don't crossfade, or the bar dips. transition={{ duration: swapIn !== undefined || swapOut ? 0 : 0.2 }} // An attribute, not a style: motion keeps the style's first value, which // left hidden rings catching clicks around an open bar. pointerEvents={hidden ? "none" : "auto"} style={{ cursor: "pointer" }} onPointerEnter={onHover} onClick={onClick} > <motion.path d={track} fill="none" stroke="currentColor" strokeOpacity={0.07} strokeWidth={stroke} strokeLinecap="round" /> <motion.path d={bar} fill="none" stroke="currentColor" strokeOpacity={strength} strokeWidth={stroke} strokeLinecap="round" /> {/* The accent fades in over the neutral stroke, since the two can't be tweened as colours. */} <motion.path d={bar} fill="none" stroke={accent} strokeWidth={stroke} strokeLinecap="round" initial={false} animate={{ opacity: focused ? 1 : 0 }} transition={{ duration: swapIn !== undefined || swapOut ? 0 : 0.2 }} /> {/* Wider invisible stroke so the ring is easy to hit. Its own pointer-events overrides the group's, so it has to switch off with it: otherwise the hidden rings stay clickable around an open bar. */} <motion.path d={track} fill="none" stroke="transparent" strokeWidth={hitWidth} pointerEvents={hidden ? "none" : "stroke"} /> </motion.g> ); }
Adds the files to
components/uibrik/ring-stack and installs @number-flow/react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/ring-stack.Props
RingStack- title
- Type string
- "October budget"
- Heading above the rings.
- accent
- Type string
- "#3b82f6"
- Colour of the ring in focus.
- currency
- Type string
- "$"
- Currency symbol for amounts.
- totalLabel
- Type string
- "spent"
- Caption under the overall percentage.
- strokeWidth
- Type number
- 12
- Thickness of each ring.
- ringGap
- Type number
- 20
- Distance between ring centres.
- morphDuration
- Type number
- 0.7
- How long a ring takes to unroll into its bar.
- items
- Type RingItem[]
- –
- Up to five rings, outermost first: { id, label, spent, budget }.
- className
- Type string
- –
- Classes for the outer shell.