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