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 fill is a stroke-dasharray on a circle
* of exact circumference (2πr), so the sweep length is computed, not eyeballed.
* Rings fill via stroke-dashoffset with a 60ms stagger on first view and
* retarget smoothly when values change.
*
* Interactive: hover or keyboard-focus a ring to surface a value tooltip and
* dim its siblings. Reduced motion renders the final state immediately.
*/
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);
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const c = size / 2;
const r = (size - strokeWidth) / 2 - 1;
const circumference = 2 * Math.PI * r;
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 dash = circumference * fraction;
const ringColor = item.color ?? color;
const dimmed = active !== null && active !== i;
return (
<div
key={item.label}
role="button"
tabIndex={0}
aria-label={`${item.label}: ${formatValue(item.value)}${
item.detail ? ` (${item.detail})` : ""
}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
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 / focus */}
{active === i && !reducedMotion && (
<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}
/>
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={ringColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
strokeDasharray={`${dash.toFixed(3)} ${circumference.toFixed(3)}`}
transform={`rotate(-90 ${c} ${c})`}
style={{
strokeDashoffset: armed ? 0 : dash,
transition: animate
? `stroke-dashoffset 600ms var(--ease-out) ${i * 60}ms`
: undefined,
}}
/>
</svg>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center text-[11px] font-medium tabular-nums">
{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>
);
}