Circular progress with a rolling percentage that closes into a drawn tick.
01Preview
Uploading 3 files
0.0 of 20.3 MB
- q3-forecast.xlsxWaiting
- brand-guidelines.pdfWaiting
- team-offsite.jpgWaiting
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react @number-flow/react motion03Usage
import { ProgressRing } from "@/components/ui/progress-ring";
<ProgressRing value={percent} size="lg" aria-label="All uploads" />
// Icon-sized, beside a file name
<ProgressRing value={file.progress} size="sm" aria-label={file.name} />
// Size not known yet
<ProgressRing value={null} aria-label="Waiting for the server" />04Source
"use client";
import { Progress } from "@base-ui/react/progress";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
// The indeterminate spin is a CSS loop so it keeps turning while the main thread
// is busy. Reduced motion holds the arc still and breathes it instead.
const CSS = `
@keyframes stealth-ring-turn { to { transform: rotate(360deg); } }
@keyframes stealth-ring-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.stealth-ring [data-part="spin"] { animation: stealth-ring-turn 0.9s linear infinite; transform-origin: center; transform-box: fill-box; }
@media (prefers-reduced-motion: reduce) {
.stealth-ring [data-part="spin"] { animation: stealth-ring-breathe 2.4s ease-in-out infinite !important; }
}
`;
const SIZES = {
sm: { px: 20, stroke: 2, text: "" },
md: { px: 40, stroke: 3, text: "text-[10.5px]" },
lg: { px: 64, stroke: 4, text: "text-[14px] font-medium tracking-[-0.01em]" },
} as const;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
export type ProgressRingProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Current value. null spins: the work has started but its size isn't known. */
value: number | null;
min?: number;
max?: number;
/** 20px (icon size, no number), 40px or 64px. */
size?: keyof typeof SIZES;
/** The rolling percentage in the center. Defaults to true for md and lg. */
showValue?: boolean;
/** The work stopped. The arc and the number turn the danger color. */
error?: boolean;
/** Replace the center, e.g. an icon or "3/12". The completion tick still takes over at the end. */
children?: React.ReactNode;
getAriaValueText?: (formattedValue: string, value: number | null) => string;
};
export function ProgressRing({
value,
min = 0,
max = 100,
size = "md",
showValue,
error = false,
children,
getAriaValueText,
className,
style,
...rest
}: ProgressRingProps) {
const reduce = useReducedMotion();
const { px, stroke, text } = SIZES[size];
const indeterminate = value === null || !Number.isFinite(value);
const pct = indeterminate ? 0 : clamp01(((value as number) - min) / (max - min || 1)) * 100;
const complete = !indeterminate && pct >= 100 && !error;
const withValue = showValue ?? size !== "sm";
const r = (px - stroke) / 2;
const c = px / 2;
const state = error ? "error" : complete ? "complete" : indeterminate ? "indeterminate" : "progressing";
const arcColor = error ? "text-danger" : complete ? "text-success" : "text-fg";
return (
<Progress.Root
value={indeterminate ? null : value}
min={min}
max={max}
getAriaValueText={(formatted, v) => (getAriaValueText ? getAriaValueText(formatted, v) : error ? `${formatted}, failed` : complete ? "Complete" : (formatted ?? "Loading"))}
data-state={state}
data-size={size}
className={cn("stealth-ring relative inline-grid shrink-0 place-items-center align-middle", className)}
style={{ width: px, height: px, ...style }}
{...rest}
>
<style href="stealth-progress-ring" precedence="default">
{CSS}
</style>
{/* A small settle when it completes: the ring answers the moment it closes. */}
<motion.svg
width={px}
height={px}
viewBox={`0 0 ${px} ${px}`}
fill="none"
aria-hidden
className="absolute inset-0 -rotate-90"
animate={complete && !reduce ? { scale: [1, 1.06, 1] } : { scale: 1 }}
transition={{ duration: 0.42, ease: ease.out, delay: 0.38 }}
>
<circle cx={c} cy={c} r={r} stroke="currentColor" strokeWidth={stroke} className="text-line-2" />
{indeterminate ? (
<circle
data-part="spin"
cx={c}
cy={c}
r={r}
pathLength={100}
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray="28 72"
className={arcColor}
/>
) : (
<circle
cx={c}
cy={c}
r={r}
pathLength={100}
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray="100 100"
// A zero-length arc with round caps would draw a dot; hide it until there is progress.
style={{ strokeDashoffset: 100 - pct, opacity: pct > 0 ? 1 : 0 }}
className={cn("transition-[stroke-dashoffset,color,opacity] duration-[480ms] ease-out-quart motion-reduce:duration-150", arcColor)}
/>
)}
</motion.svg>
<span className={cn("tabular relative grid place-items-center leading-none", text, error ? "text-danger" : "text-fg")}>
<AnimatePresence initial={false}>
{complete ? (
<motion.span
key="done"
className="col-start-1 row-start-1 grid place-items-center text-success"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={reduce ? { duration: 0.15 } : { ...spring.pop, delay: 0.3 }}
>
<Tick px={px} reduce={!!reduce} />
</motion.span>
) : children != null ? (
<motion.span key="custom" className="col-start-1 row-start-1 grid place-items-center" exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.12 } }}>
{children}
</motion.span>
) : withValue && !indeterminate ? (
<motion.span
key="value"
aria-hidden
className="col-start-1 row-start-1"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
// Waits for the arc to close before making way for the tick.
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: "blur(2px)", transition: { duration: 0.14, delay: 0.24 } }}
transition={{ duration: 0.2, ease: ease.out }}
>
<NumberFlow value={pct / 100} format={{ style: "percent", maximumFractionDigits: 0 }} animated={!reduce} willChange />
</motion.span>
) : null}
</AnimatePresence>
</span>
</Progress.Root>
);
}
function Tick({ px, reduce }: { px: number; reduce: boolean }) {
const s = Math.round(px * 0.45);
return (
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={px < 32 ? 2 : 1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.34, ease: ease.out, delay: 0.36 }}
/>
</svg>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | null | — | Current value. null spins a short arc until the size is known. |
| min | number | 0 | Start of the range. |
| max | number | 100 | End of the range. Reaching it turns the ring into a tick. |
| size | "sm" | "md" | "lg" | "md" | 20px (icon size), 40px or 64px, with 2, 3 or 4px strokes. |
| showValue | boolean | true for md and lg | The rolling percentage in the center. |
| error | boolean | false | The work stopped. The arc and number turn the danger color and hold where they stopped. |
| children | ReactNode | — | Custom center content, e.g. an icon or a count. The completion tick still takes over at the end. |
| getAriaValueText | (formatted: string, value: number | null) => string | — | What screen readers hear. By default the percentage, "failed" or "Complete". |
06Notes
Behavior
- The arc is one circle with a normalized path length, so the same 0–100 dash maths works at every size; a zero-length arc is hidden so round caps never leave a dot at 0%.
- At max the arc closes, the number steps aside and a tick draws in its place. The arc turns the success color, so a glance at a list tells done from not done.
- Values outside min–max are clamped; a non-finite value spins instead of drawing a broken arc.
- The sm size has no number: it is meant to sit in an icon's slot beside the text that says the rest.
Motion
- The arc eases to each new value over 480ms (ease-out-quart), so frequent updates read as one continuous sweep.
- Completion is sequenced: the arc closes, the number blurs out after 240ms, the tick springs in at 300ms and draws over 340ms, and the ring settles with a single 6% scale at 380ms.
- Indeterminate: a 28% arc turns once every 0.9s, linear, in CSS so it keeps turning under load.
- Reduced motion: digits don't roll, nothing scales or spins; the indeterminate arc holds still and breathes its opacity over 2.4s.
Accessibility
- Built on the Progress primitive: role="progressbar" with aria-valuenow, min and max. Name it with aria-label or aria-labelledby.
- aria-valuetext says "Complete" at the end and appends "failed" on error, so the state isn't carried by color alone.
- The SVG and the rolling number are aria-hidden.