Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/progress-ring.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
progress-ring.tsx. It's yours: change anything.
Usage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | — | Progress so far; null spins until you know. |
| max | number | 100 | Value that fills the ring. |
| label | string | — | Accessible name, e.g. "Storage used". |
| size | number | 128 | Diameter in pixels. |
| strokeWidth | number | 10 | Ring thickness in pixels. |
| showValue | boolean | true | Show the value in the centre. |
| formatValue | (value, max) => string | rounded % | Formats the centre value and aria-valuetext. |
| gradient | boolean | true | Gradient along the arc, deep at the start and brighter at the tip; false for one solid colour. |
| glow | boolean | false | A soft halo in the ring's colour, for dark surfaces. |
| children | ReactNode | — | Replace the centre content. |
| duration | number | 1 | Seconds the arc takes to travel to a new value. |
| completeIcon | boolean | false | Swap the number for a tick when full. |
| --progress-color / -color-end / -track | CSS variable | theme | Arc 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
- v1.0.0Latest
First release.