SVG ring whose stroke-dashoffset transitions to the value while the center percentage counts along in tabular-nums.
npx shadcn@latest add @paragon/progress-ring"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>
);
}