Determinate Progress Ring
A ring fills from empty to full while the percentage counts up inside it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · Determinate Progress Ring
*
* A ring that fills from empty to its target while the percentage
* counts up in the middle. Arc and number are driven by one motion
* value, so they can never disagree.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `value`, `size`, `color`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ProgressRingProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Target percentage, 0–100. */
value?: number;
/** Ring diameter in px. */
size?: number;
/** Ring thickness in px. */
strokeWidth?: number;
/** Filled arc color. */
color?: string;
/** Unfilled track color. A translucent neutral, so it reads on light and dark. */
trackColor?: string;
/** Accessible label announced to screen readers. */
label?: string;
/** Fires once the ring reaches its target. */
onComplete?: () => void;
};
type VariantConfig = {
scaleFrom: number;
spring: { type: "spring"; stiffness: number; damping: number };
fillSeconds: number;
fillEase: "easeOut" | "easeInOut";
fillDelay: number;
};
// Quality rule: the entrance spring sits at or above critical damping —
// a progress ring that wobbles reads as a toy. Variants differ in how
// much the ring announces itself and how long the count takes to read.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// No entrance, quick front-loaded fill. For inline and in-row progress
// where the ring is one of several things on screen.
subtle: {
scaleFrom: 1,
spring: { type: "spring", stiffness: 460, damping: 46 },
fillSeconds: 1.0,
fillEase: "easeOut",
fillDelay: 0.04,
},
// A small settle in, then an eased fill. The all-purpose setting.
default: {
scaleFrom: 0.94,
spring: { type: "spring", stiffness: 420, damping: 38 },
fillSeconds: 1.25,
fillEase: "easeInOut",
fillDelay: 0.1,
},
// Larger entrance and a longer count — for a full-screen export or
// install moment where watching the number climb is the point.
playful: {
scaleFrom: 0.86,
spring: { type: "spring", stiffness: 380, damping: 32 },
fillSeconds: 1.5,
fillEase: "easeInOut",
fillDelay: 0.14,
},
};
export default function ProgressRing({
variant = "default",
value = 100,
size = 104,
strokeWidth = 8,
color = "#7C7CF0",
trackColor = "rgba(127, 127, 140, 0.22)",
label = "Progress",
onComplete,
}: ProgressRingProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const target = Math.min(100, Math.max(0, value));
// One source of truth for both the arc and the digits.
const progress = useMotionValue(0);
const pathLength = useTransform(progress, [0, 100], [0, 1]);
const readout = useTransform(progress, (current) => Math.round(current));
// The callback lives in a ref so an inline arrow from the parent can't
// re-trigger the effect and restart the fill halfway through.
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
// Reduced motion: land on the answer. The arc and the number are the
// information — the climb was only ever the delivery.
if (reduceMotion) {
progress.set(target);
onCompleteRef.current?.();
return;
}
const controls = animate(progress, target, {
duration: cfg.fillSeconds,
ease: cfg.fillEase,
delay: cfg.fillDelay,
onComplete: () => onCompleteRef.current?.(),
});
return () => controls.stop();
}, [target, reduceMotion, cfg, progress]);
const center = size / 2;
const radius = (size - strokeWidth) / 2;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
// The target, not the animated frame: assistive tech should hear
// where the work actually stands, not follow the count.
aria-valuenow={target}
style={{ position: "relative", width: size, height: size }}
>
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: cfg.scaleFrom }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.spring, opacity: { duration: 0.2, ease: "easeOut" } }
}
style={{ display: "block" }}
>
<circle
cx={center}
cy={center}
r={radius}
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{/* The dash offset starts at 3 o'clock, so the group is rotated
to put zero at the top where people read it from. */}
<g transform={`rotate(-90 ${center} ${center})`}>
<motion.circle
cx={center}
cy={center}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ pathLength }}
/>
</g>
</motion.svg>
{/* The readout fades but never scales, and the entrance spring is
on the ring only — scaling digits is the fastest way to make a
progress indicator look cheap. Tabular figures keep the number
from twitching sideways as it climbs. */}
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.25,
ease: "easeOut",
delay: reduceMotion ? 0 : cfg.fillDelay,
}}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum"',
lineHeight: 1,
}}
>
<motion.span style={{ fontSize: size * 0.26, fontWeight: 600 }}>
{readout}
</motion.span>
<span
style={{
fontSize: size * 0.15,
fontWeight: 600,
opacity: 0.5,
marginLeft: 1,
}}
>
%
</span>
</motion.div>
</div>
);
}About this pattern
For work whose end is genuinely known: uploads, exports, batch imports, multi-step installs. The arc and the number are driven by a single value, so they can never disagree, and the readout uses tabular figures so the digits climb without twitching sideways. The number never scales or bounces — a figure that deforms while it counts stops reading as data and starts reading as decoration. Use a breathing indicator instead whenever the finish line is a guess.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
Determinate rings whose fill maps directly onto a known value.
Related patterns
- File Upload ProgressA file row's bar fills as bytes land while the percentage and the counter roll with it.
- Download Arrow ProgressThe download glyph floods with color from the bottom as the transfer advances.
- Long Task EstimateA bar for work measured in minutes, with the time-remaining wording softening as it runs out.