A cluster of small labeled progress rings that fill with a 60ms stagger on first view and retarget smoothly on value changes.
npx shadcn@latest add @paragon/radial-progress-group"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface RadialProgressItem {
label: string;
/** Percent complete, 0–100. */
value: number;
/** Secondary line under the label, e.g. "412 / 660 GB". */
detail?: string;
color?: string;
}
export interface RadialProgressGroupProps extends React.ComponentProps<"div"> {
items: RadialProgressItem[];
/** Ring diameter in px. */
size?: number;
strokeWidth?: number;
/** Horizontal gap between rings, in px. */
gap?: number;
/** Default ring color; per-item `color` overrides. */
color?: string;
formatValue?: (value: number) => string;
/** Renders the final state immediately, no fill-in. */
static?: boolean;
}
/**
* A cluster of small labeled progress rings — per-region capacity, rollout
* coverage, quota by team. Each ring's dash pattern is fixed at the exact
* circumference (2πr) and only stroke-dashoffset moves, so the fill sweeps in
* with a 60ms stagger on first view AND retargets mid-flight when a value
* changes — one interruptible transition covers both. The center readout
* scales with the ring diameter.
*
* Interactive: hover, tap, or keyboard-focus a ring (arrows traverse, Home
* and End jump) to surface a value tooltip and dim its siblings. Reduced
* motion renders the final state immediately; tooltips stay.
*/
export function RadialProgressGroup({
items,
size = 64,
strokeWidth = 5,
gap = 32,
color = "oklch(0.585 0.17 260)",
formatValue = (v) => `${Math.round(v)}%`,
static: isStatic = false,
className,
...props
}: RadialProgressGroupProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -24px 0px",
});
const [active, setActive] = React.useState<number | null>(null);
// Once the staggered sweep-in finishes, drop the per-ring delays so value
// changes retarget immediately instead of queueing behind them.
const [entered, setEntered] = React.useState(false);
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
React.useEffect(() => {
if (!animate || !inView) return;
const timeout = setTimeout(
() => setEntered(true),
600 + items.length * 60,
);
return () => clearTimeout(timeout);
}, [animate, inView, items.length]);
const c = size / 2;
const r = (size - strokeWidth) / 2 - 1;
const circumference = 2 * Math.PI * r;
const valueFontSize = Math.round(Math.min(Math.max(size * 0.17, 10), 16));
if (items.length === 0) {
return (
<div
ref={containerRef}
data-slot="radial-progress-group"
className={cn(
"flex min-h-24 items-center justify-center text-sm text-muted-foreground",
className,
)}
{...props}
>
No data
</div>
);
}
return (
<div
ref={containerRef}
data-slot="radial-progress-group"
className={cn("flex flex-wrap", className)}
style={{ columnGap: gap, rowGap: 24 }}
onPointerLeave={() => setActive(null)}
{...props}
>
{items.map((item, i) => {
const fraction = Math.min(Math.max(item.value / 100, 0), 1);
const ringColor = item.color ?? color;
const dimmed = active !== null && active !== i;
return (
<div
key={item.label}
role="img"
tabIndex={0}
aria-label={`${item.label}: ${formatValue(item.value)}${
item.detail ? ` (${item.detail})` : ""
}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
onKeyDown={(e) => {
const target =
e.key === "ArrowRight" || e.key === "ArrowDown"
? Math.min(items.length - 1, i + 1)
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? Math.max(0, i - 1)
: e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: null;
if (target === null) return;
e.preventDefault();
(
e.currentTarget.parentElement?.querySelectorAll(
"[data-radial-item]",
)[target] as HTMLElement | undefined
)?.focus();
}}
data-radial-item
className="group flex cursor-default flex-col items-center gap-2 rounded-lg px-1 outline-none transition-opacity duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
style={{ opacity: dimmed ? 0.5 : 1 }}
>
<div className="relative" style={{ width: size, height: size }}>
{/* Value tooltip on hover / tap / focus — stays under reduced motion */}
{active === i && (
<div
role="status"
className="pointer-events-none absolute -top-2 left-1/2 z-10 -translate-x-1/2 -translate-y-full rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground shadow-overlay"
style={{ whiteSpace: "nowrap" }}
>
<span className="tabular-nums">{formatValue(item.value)}</span>
{item.detail && (
<span className="ml-1.5 tabular-nums opacity-80">
{item.detail}
</span>
)}
</div>
)}
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="block"
aria-hidden
>
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.09}
strokeWidth={strokeWidth}
/>
{/* Fixed dash = circumference; only the offset ever moves, so
the sweep-in and any later value change share one
interruptible transition. */}
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={ringColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference.toFixed(3)}
transform={`rotate(-90 ${c} ${c})`}
style={{
strokeDashoffset: armed
? circumference * (1 - fraction)
: circumference,
// Hide the round-cap dot a zero-length dash would leave.
opacity: fraction === 0 ? 0 : 1,
transition: animate
? `stroke-dashoffset 600ms var(--ease-out) ${
entered ? 0 : i * 60
}ms, opacity 150ms var(--ease-out)`
: "opacity 150ms var(--ease-out)",
}}
/>
</svg>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center font-medium tabular-nums"
style={{ fontSize: valueFontSize }}
>
{formatValue(item.value)}
</span>
</div>
<span className="flex max-w-20 flex-col items-center">
<span className="max-w-full truncate text-xs font-medium">
{item.label}
</span>
{item.detail && (
<span className="mt-0.5 max-w-full truncate text-[11px] text-muted-foreground tabular-nums">
{item.detail}
</span>
)}
</span>
</div>
);
})}
</div>
);
}