A circular SVG progress indicator whose arc and center percentage tween smoothly to each new value, with an optional two-color gradient stroke, soft glow, rounded caps and a caption.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add progress-ringWrites components/ui/progress-ring.tsx and components/styles/progress-ring.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ProgressRing } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ProgressRing />6 );7}Props
Accessibility
The root has role="progressbar" with aria-valuemin, aria-valuemax, aria-valuenow (the target, not the tweening value), aria-valuetext as a percentage and aria-label from the caption. The SVG is aria-hidden. Under prefers-reduced-motion the value jumps without tweening.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/progress-ring.module.css";5 6export interface ProgressRingProps {7 /** Current progress, clamped between 0 and `max` */8 value?: number;9 /** Value that represents a full ring */10 max?: number;11 /** Outer diameter in pixels */12 size?: number;13 /** Stroke thickness in pixels */14 thickness?: number;15 /** Progress stroke color */16 color?: string;17 /** Second color for a gradient stroke (used when `gradient` is true) */18 color2?: string;19 /** Blend `color` into `color2` along the arc */20 gradient?: boolean;21 /** Track color (defaults to the theme border color) */22 trackColor?: string;23 /** Caption shown under the number */24 label?: string;25 /** Show the numeric percentage in the center */26 showValue?: boolean;27 /** Round the ends of the progress arc */28 roundedCaps?: boolean;29 /** Tween duration in milliseconds (0 = instant) */30 duration?: number;31 /** Optional custom center content (replaces the number + caption) */32 children?: React.ReactNode;33 /** Optional className merged onto the root */34 className?: string;35 /** Optional inline styles merged onto the root */36 style?: React.CSSProperties;37}38 39const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);40 41export function ProgressRing({42 value = 72,43 max = 100,44 size = 168,45 thickness = 12,46 color = "#6366f1",47 color2 = "#22d3ee",48 gradient = true,49 trackColor,50 label = "Storage used",51 showValue = true,52 roundedCaps = true,53 duration = 1200,54 children,55 className = "",56 style,57}: ProgressRingProps) {58 const id = React.useId().replace(/:/g, "");59 const safeMax = max > 0 ? max : 100;60 const target = Math.min(safeMax, Math.max(0, value));61 const [shown, setShown] = React.useState(0);62 const shownRef = React.useRef(0);63 64 React.useEffect(() => {65 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;66 const from = shownRef.current;67 if (reduce || duration <= 0 || from === target) {68 shownRef.current = target;69 setShown(target);70 return;71 }72 let raf = 0;73 let start = 0;74 const tick = (now: number) => {75 if (!start) start = now;76 const t = Math.min(1, (now - start) / duration);77 const v = from + (target - from) * easeOutCubic(t);78 shownRef.current = v;79 setShown(v);80 if (t < 1) raf = requestAnimationFrame(tick);81 };82 raf = requestAnimationFrame(tick);83 return () => cancelAnimationFrame(raf);84 }, [target, duration]);85 86 const stroke = Math.max(1, Math.min(thickness, size / 2 - 1));87 const r = (size - stroke) / 2;88 const c = 2 * Math.PI * r;89 const pct = shown / safeMax;90 const offset = c * (1 - pct);91 const percent = Math.round(pct * 100);92 const strokeColor = gradient ? `url(#${id}-g)` : color;93 94 return (95 <div96 role="progressbar"97 aria-valuemin={0}98 aria-valuemax={safeMax}99 aria-valuenow={Math.round(target)}100 aria-valuetext={`${Math.round((target / safeMax) * 100)}%`}101 aria-label={label || "Progress"}102 className={`${styles.root} ${className}`.trim()}103 style={{ "--mf-pr-size": `${size}px`, "--mf-pr-color": color, ...style } as React.CSSProperties}104 >105 <svg aria-hidden="true" className={styles.svg} width={size} height={size} viewBox={`0 0 ${size} ${size}`}>106 {gradient && (107 <defs>108 <linearGradient id={`${id}-g`} x1="0" y1="0" x2="1" y2="1">109 <stop offset="0%" stopColor={color} />110 <stop offset="100%" stopColor={color2} />111 </linearGradient>112 </defs>113 )}114 <circle115 className={styles.track}116 cx={size / 2}117 cy={size / 2}118 r={r}119 fill="none"120 strokeWidth={stroke}121 style={trackColor ? { stroke: trackColor } : undefined}122 />123 <circle124 className={styles.bar}125 cx={size / 2}126 cy={size / 2}127 r={r}128 fill="none"129 stroke={strokeColor}130 strokeWidth={stroke}131 strokeLinecap={roundedCaps && pct > 0.002 ? "round" : "butt"}132 strokeDasharray={c}133 strokeDashoffset={offset}134 transform={`rotate(-90 ${size / 2} ${size / 2})`}135 />136 </svg>137 <div className={styles.center}>138 {children ?? (139 <>140 {showValue && (141 <span className={styles.value}>142 {percent}143 <span className={styles.unit}>%</span>144 </span>145 )}146 {label && <span className={styles.label}>{label}</span>}147 </>148 )}149 </div>150 </div>151 );152}