Circular Progress
A ring fills to the new value while the number in the middle counts up with it.
Build a React circular progress ring in SVG. Draw two circles with r = 52 in a 120×120 viewBox: a track in a muted colour and a progress circle with stroke-dasharray equal to the circumference (2πr) and stroke-dashoffset = circumference × (1 − value). Rotate the progress circle −90° so it starts at 12 o’clock and use stroke-linecap: round. Animate from the old to the new value with requestAnimationFrame over 900ms using an ease-out cubic curve, updating both the dashoffset and the percentage text in the centre on each frame (tabular-nums). Expose it as role="progressbar" with aria-valuenow, aria-valuemin 0, aria-valuemax 100 and aria-label "Profile complete". Add a button that sets a new random value. Under prefers-reduced-motion, jump straight to the new value.
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const R = 52
const C = 2 * Math.PI * R
export default function CircularProgress({ labels }: { labels: Record<string, string> }) {
const [shown, setShown] = useState(0)
const [goal, setGoal] = useState(0)
const target = useRef(0)
const frame = useRef(0)
const animateTo = (next: number) => {
cancelAnimationFrame(frame.current)
const from = target.current
target.current = next
setGoal(next)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setShown(next)
const t0 = performance.now()
const tick = (now: number) => {
const t = Math.min((now - t0) / 900, 1)
setShown(from + (next - from) * (1 - (1 - t) ** 3))
if (t < 1) frame.current = requestAnimationFrame(tick)
}
frame.current = requestAnimationFrame(tick)
}
useEffect(() => {
// start on the next frame so the ring animates in from 0 after mount
frame.current = requestAnimationFrame(() => animateTo(72))
return () => cancelAnimationFrame(frame.current)
}, [])
const pct = Math.round(shown)
return (
<div className={s.wrap}>
<div className={s.ring} role="progressbar" aria-valuenow={goal} aria-valuemin={0} aria-valuemax={100} aria-label={labels.label}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className={s.track} cx="60" cy="60" r={R} />
<circle className={s.bar} cx="60" cy="60" r={R} strokeDasharray={C} strokeDashoffset={C * (1 - shown / 100)} />
</svg>
<span className={s.value}>{pct}%</span>
</div>
<span className={s.label}>{labels.label}</span>
<button type="button" className={s.button} onClick={() => animateTo(10 + Math.floor(Math.random() * 90))}>
{labels.again}
</button>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 8px; }
.ring { position: relative; width: 120px; height: 120px; }
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.track, .bar { fill: none; stroke-width: 10; }
.track { stroke: var(--stage-rule); }
.bar { stroke: var(--stage-accent); stroke-linecap: round; }
.value {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font: 600 var(--text-2xl) / 1 var(--font-display);
font-variant-numeric: tabular-nums;
color: var(--stage-ink);
}
.label { font-size: var(--text-xs); color: var(--stage-muted); }
.button {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 8px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }