Feedback

Progress Ring

SVG ring whose stroke-dashoffset transitions to the value while the center percentage counts along in tabular-nums.

Install

npx shadcn@latest add @paragon/progress-ring

progress-ring.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface ProgressRingProps extends React.ComponentProps<"div"> {
  /** 0–100. */
  value: number;
  /** Diameter in px. */
  size?: number;
  /** Stroke thickness in px. */
  thickness?: number;
  /** Shows the percentage in the center. */
  showValue?: boolean;
  /** Accessible name for the progressbar. */
  label?: string;
}

/** Ease-out counting toward the target, retargeting from the current value. */
function useAnimatedNumber(target: number, enabled: boolean) {
  const [display, setDisplay] = React.useState(target);
  const displayRef = React.useRef(target);
  displayRef.current = display;

  React.useEffect(() => {
    if (!enabled) {
      setDisplay(target);
      return;
    }
    const from = displayRef.current;
    if (from === target) return;
    const start = performance.now();
    const duration = 250;
    let raf: number;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      // Approximates var(--ease-out).
      const eased = 1 - Math.pow(1 - t, 3);
      setDisplay(from + (target - from) * eased);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, enabled]);

  return display;
}

/**
 * SVG progress ring: stroke-dashoffset transitions to the value
 * (interruptible), the center percentage counts alongside it with
 * tabular-nums.
 */
export function ProgressRing({
  value,
  size = 64,
  thickness = 6,
  showValue = true,
  label = "Progress",
  className,
  style,
  ...props
}: ProgressRingProps) {
  const reduced = useReducedMotion();
  const pct = Math.min(100, Math.max(0, value));
  const radius = (size - thickness) / 2;
  const circumference = 2 * Math.PI * radius;
  const display = useAnimatedNumber(pct, !reduced);

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(pct)}
      className={cn(
        "relative inline-flex items-center justify-center",
        className,
      )}
      style={{ width: size, height: size, ...style }}
      {...props}
    >
      <svg
        aria-hidden
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        fill="none"
        className="-rotate-90"
      >
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke="var(--color-secondary)"
          strokeWidth={thickness}
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke="var(--color-primary)"
          strokeWidth={thickness}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={circumference * (1 - pct / 100)}
          className="transition-[stroke-dashoffset] duration-250 ease-[var(--ease-out)] motion-reduce:transition-none"
        />
      </svg>
      {showValue && (
        <span
          aria-hidden
          className="absolute font-medium text-foreground tabular-nums"
          style={{ fontSize: Math.max(11, size * 0.22) }}
        >
          {Math.round(display)}%
        </span>
      )}
    </div>
  );
}