Skip to content
Uxelith
Sign in
Freev1.0.0Data

Progress Ring

A circular progress indicator whose gradient follows the arc, with a shadowed cap at the tip as it closes. Arc and number move together; indeterminate spin, a tick when full, stack several for activity rings.

pnpm dlx shadcn add …/r/progress-ring.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/progress-ring.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as progress-ring.tsx. It's yours: change anything.

Usage

example.tsx
import { ProgressRing } from "@/components/progress-ring"

export function Storage() {
  return <ProgressRing value={72} label="Storage used" />
}

Source

One file, 231 lines, nothing hidden. Read it before you install it.

progress-ring.tsx
"use client";

import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface ProgressRingProps extends Omit<React.ComponentProps<"div">, "children"> {
  /** Progress so far; `null` for an indeterminate ring that spins until you know. */
  value: number | null;
  max?: number;
  /** Accessible name, e.g. "Storage used". Required: a bare number means nothing to a screen reader. */
  label: string;
  /** Diameter in pixels. */
  size?: number;
  /** Ring thickness in pixels. */
  strokeWidth?: number;
  /** Show the value in the centre. */
  showValue?: boolean;
  /** How the centre value (and `aria-valuetext`) reads. Defaults to a rounded percentage. */
  formatValue?: (value: number, max: number) => string;
  /** Paint the arc with a gradient that follows it round, deep at the start and brighter at the tip. Off: one solid colour. */
  gradient?: boolean;
  /** A soft halo in the ring's colour, for dark surfaces. */
  glow?: boolean;
  /** Seconds the arc takes to travel to a new value. */
  duration?: number;
  /** Swap the centre for a tick when the ring is full. */
  completeIcon?: boolean;
  /** Replace the centre content entirely. */
  children?: React.ReactNode;
}

// Motion presets (ADR-010).
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;
const percent = (value: number, max: number) => `${Math.round((value / max) * 100)}%`;

/**
 * A circular progress indicator.
 *
 * - `role="progressbar"` with min, max, now and a readable `aria-valuetext`;
 *   `value={null}` makes it indeterminate (no `aria-valuenow`).
 * - The arc and the centre number move together on every change, driven
 *   outside React so nothing re-renders per frame. Full rings can swap the
 *   number for a tick that pops in.
 * - The gradient runs along the arc (an SVG stroke can't take a conic
 *   gradient, so each half of the ring carries its own), and a cap at the
 *   tip casts a soft shadow as it closes the circle.
 * - Colours: `--progress-color`, `--progress-color-end` (defaults to a
 *   brighter neighbour of the first) and `--progress-track` (defaults to a
 *   tint of the ring). Under reduced motion values jump and the
 *   indeterminate ring holds still.
 */
export function ProgressRing({
  value,
  max = 100,
  label,
  size = 128,
  strokeWidth = 10,
  showValue = true,
  formatValue = percent,
  gradient = true,
  glow = false,
  duration = 1,
  completeIcon = false,
  className,
  style,
  children,
  ...props
}: ProgressRingProps) {
  const id = React.useId();
  const reduceMotion = useReducedMotion();
  const indeterminate = value === null;
  const target = indeterminate ? 0 : Math.min(Math.max(value, 0), max);
  const complete = !indeterminate && target >= max;

  const c = size / 2;
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;
  // One value (the filled fraction) drives both the arc and the number, so
  // they never disagree.
  const fraction = useMotionValue(0);
  const firstRef = React.useRef<SVGPathElement>(null);
  const secondRef = React.useRef<SVGPathElement>(null);
  const tipRef = React.useRef<SVGCircleElement>(null);
  const textRef = React.useRef<HTMLSpanElement>(null);

  React.useEffect(() => {
    if (indeterminate) return;
    const to = target / max;
    if (reduceMotion) {
      fraction.set(to);
      return;
    }
    const controls = animate(fraction, to, { duration, ease: [0.22, 1, 0.36, 1] });
    return () => controls.stop();
  }, [indeterminate, target, max, reduceMotion, duration, fraction]);

  // The arc and the centre number follow the value without re-rendering
  // React every frame. Written straight to the DOM, current value first, so
  // nothing depends on a change event that may already have fired (as it
  // has when React's Strict Mode re-runs effects).
  React.useEffect(() => {
    const write = (f: number) => {
      const first = firstRef.current;
      const second = secondRef.current;
      const tip = tipRef.current;
      if (first && second && tip) {
        first.style.strokeDashoffset = String(1 - Math.min(f * 2, 1));
        second.style.strokeDashoffset = String(1 - Math.max(f * 2 - 1, 0));
        // A round cap on an empty arc would still draw a dot.
        first.style.opacity = f <= 0 ? "0" : "1";
        second.style.opacity = f <= 0.5 ? "0" : "1";
        const angle = f * 2 * Math.PI;
        tip.setAttribute("cx", String(c + radius * Math.cos(angle)));
        tip.setAttribute("cy", String(c + radius * Math.sin(angle)));
        tip.setAttribute("fill", `url(#${id}-${f <= 0.5 ? "a" : "b"})`);
        tip.style.opacity = f <= 0 ? "0" : "1";
        // The shadow falls ahead of the tip, onto the start of the ring it
        // is about to overlap, and only shows as the circle closes.
        const depth = Math.min(Math.max((f - 0.8) / 0.2, 0), 1);
        const dx = -Math.sin(angle) * strokeWidth * 0.12;
        const dy = Math.cos(angle) * strokeWidth * 0.12;
        tip.style.filter = depth > 0 ? `drop-shadow(${dx}px ${dy}px ${strokeWidth * 0.15}px rgb(0 0 0 / ${0.45 * depth}))` : "none";
      }
      if (textRef.current) textRef.current.textContent = formatValue(f * max, max);
    };
    write(fraction.get());
    return fraction.on("change", write);
  }, [fraction, formatValue, max, indeterminate, c, radius, strokeWidth, id]);

  const end = gradient ? "var(--progress-color-end)" : "var(--progress-color)";
  const mid = `color-mix(in oklab, var(--progress-color), ${end})`;
  // Drawn from 3 o'clock clockwise; the svg is turned so it starts at 12.
  const arc = { fill: "none", strokeWidth, strokeLinecap: "round", pathLength: 1, strokeDasharray: 1, style: { strokeDashoffset: 1, opacity: 0 } } as const;

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={indeterminate ? undefined : target}
      aria-valuetext={indeterminate ? "Loading" : formatValue(target, max)}
      aria-busy={indeterminate || undefined}
      className={cn(
        "[--progress-color:var(--primary)] [--progress-color-end:oklch(from_var(--progress-color)_calc(l+0.1)_c_calc(h-28))]",
        "[--progress-track:color-mix(in_oklab,var(--progress-color)_16%,transparent)]",
        "relative inline-grid shrink-0 place-items-center",
        className,
      )}
      style={{ width: size, height: size, ...style }}
      {...props}
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        className="-rotate-90 overflow-visible"
        style={glow ? { filter: "drop-shadow(0 0 8px color-mix(in oklab, var(--progress-color) 40%, transparent))" } : undefined}
        aria-hidden="true"
      >
        <defs>
          <linearGradient id={`${id}-a`} gradientUnits="userSpaceOnUse" x1={c + radius} y1={c} x2={c - radius} y2={c}>
            <stop offset="0" style={{ stopColor: "var(--progress-color)" }} />
            <stop offset="1" style={{ stopColor: mid }} />
          </linearGradient>
          <linearGradient id={`${id}-b`} gradientUnits="userSpaceOnUse" x1={c - radius} y1={c} x2={c + radius} y2={c}>
            <stop offset="0" style={{ stopColor: mid }} />
            <stop offset="1" style={{ stopColor: end }} />
          </linearGradient>
        </defs>
        <circle cx={c} cy={c} r={radius} fill="none" stroke="var(--progress-track)" strokeWidth={strokeWidth} />
        {indeterminate ? (
          <circle
            cx={c}
            cy={c}
            r={radius}
            fill="none"
            stroke={`url(#${id}-b)`}
            strokeWidth={strokeWidth}
            strokeLinecap="round"
            strokeDasharray={`${circumference * 0.28} ${circumference}`}
            className="origin-center animate-spin [animation-duration:1.1s] motion-reduce:animate-none"
          />
        ) : (
          <>
            <path ref={firstRef} d={`M ${c + radius} ${c} A ${radius} ${radius} 0 0 1 ${c - radius} ${c}`} stroke={`url(#${id}-a)`} {...arc} />
            <path ref={secondRef} d={`M ${c - radius} ${c} A ${radius} ${radius} 0 0 1 ${c + radius} ${c}`} stroke={`url(#${id}-b)`} {...arc} />
            <circle ref={tipRef} cx={c + radius} cy={c} r={strokeWidth / 2} fill={`url(#${id}-a)`} style={{ opacity: 0 }} />
          </>
        )}
      </svg>
      <div className="absolute inset-0 grid place-items-center text-center" style={{ fontSize: Math.max(12, size * 0.2) }}>
        {children ??
          (showValue && !indeterminate && (
            <AnimatePresence initial={false} mode="popLayout">
              {complete && completeIcon ? (
                <motion.svg
                  key="done"
                  data-part="tick"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke={end}
                  strokeWidth="2.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="size-[1.8em]"
                  initial={{ opacity: 0, scale: 0.3 }}
                  animate={{ opacity: 1, scale: 1 }}
                  transition={reduceMotion ? { duration: 0 } : { ...SPRING_BOUNCY, delay: duration * 0.8 }}
                >
                  <path d="m5 12.5 4.5 4.5L19 7.5" />
                </motion.svg>
              ) : (
                <motion.span
                  key="value"
                  ref={textRef}
                  className="font-semibold tabular-nums tracking-tight text-foreground"
                  exit={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
                  transition={reduceMotion ? { duration: 0 } : { duration: 0.2, delay: complete ? duration * 0.7 : 0 }}
                >
                  {formatValue(0, max)}
                </motion.span>
              )}
            </AnimatePresence>
          ))}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
valuenumber | null—Progress so far; null spins until you know.
maxnumber100Value that fills the ring.
labelstring—Accessible name, e.g. "Storage used".
sizenumber128Diameter in pixels.
strokeWidthnumber10Ring thickness in pixels.
showValuebooleantrueShow the value in the centre.
formatValue(value, max) => stringrounded %Formats the centre value and aria-valuetext.
gradientbooleantrueGradient along the arc, deep at the start and brighter at the tip; false for one solid colour.
glowbooleanfalseA soft halo in the ring's colour, for dark surfaces.
childrenReactNode—Replace the centre content.
durationnumber1Seconds the arc takes to travel to a new value.
completeIconbooleanfalseSwap the number for a tick when full.
--progress-color / -color-end / -trackCSS variablethemeArc start, gradient end (a brighter neighbour by default) and track (a tint of the arc by default).

Accessibility

  • role="progressbar" with aria-valuemin, aria-valuemax, aria-valuenow and a readable aria-valuetext.
  • A required label so the number is never announced without context.
  • Indeterminate rings drop aria-valuenow and set aria-busy.
  • Under prefers-reduced-motion the arc and number jump straight to the new value and the indeterminate ring holds still.

Changelog

  1. v1.0.0Latest

    First release.

More in Data

All 9 in Data
  • DataPro

    Area Chart

    Trends over time: lines draw themselves in, the crosshair glides between points, the area you've scrubbed stays bright, and the tooltip shows the change from the point before.

  • DataFree

    Avatar Stack

    Overlapping avatars that spread on hover, with a name tooltip that swings with the pointer, status dots and a +N list of everyone else. Photos or initials.

  • DataPro

    Bar Chart

    A responsive bar chart, grouped or stacked: bars rise in one after another, the hovered column lights up and the tooltip glides, with keyboard navigation and a hidden data table.