Radial Progress Group
Charts

Radial Progress Group

A cluster of small labeled progress rings that fill with a 60ms stagger on first view and retarget smoothly on value changes.

Install

npx shadcn@latest add @paragon/radial-progress-group

radial-progress-group.tsx

"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>
  );
}