These are concentric progress rings in the style of Apple's activity rings1, drawn with CSS gradients and masks - no SVG and no canvas. Each ring is one number, its progress, and everything you see is derived from it: the arc, the colour sweep, the round end and the shadow it casts once the ring goes past its goal. I saw these rings on my iPhone and have always liked them, and they are a good way to show data. I built it together with an AI agent. The full code is at the end.
What went wrong first
On a phone the steppers sit under the rings, and on a wider screen they move beside them. The first time they moved, the rings squashed into ovals. The agent caught it in a desktop screenshot. The rings were a flex item in a row, and a flex item is allowed to shrink below its width by default, so the row took width from the rings while their height stayed the same. One line fixes it: flex-shrink: 0 on the rings, so they keep the size they were given.
Why not SVG
A ring like this needs a gradient that follows the angle around the circle, and SVG has no angular gradient. CSS does: conic-gradient2 sweeps colour around a centre point, starting at 12 o'clock and going clockwise, which is exactly how a ring fills. Each ring is a square element with that gradient, cut down to a ring by a radial-gradient mask3 that is transparent in the middle and outside the stroke. A second mask, another conic-gradient that is solid up to --sweep and transparent after it, cuts the arc to its progress.
The colour sweep is tied to the angle, not to the length of the arc. A short arc only shows the start of the sweep, and the gradient never squashes as the arc grows.
The round ends
Masks give hard, square ends, so each ring draws two discs on top: one at 12 o'clock in the start colour and one at the head. The head disc sits in a full-size layer rotated by --head, so it travels around the ring without any trigonometry. Its colour is the colour the sweep has under it, mixed with color-mix()4 from the same progress number.
One detail that is easy to miss: the gradient and the mix have to blend in the same colour space. The gradients blend in oklab and color-mix() was mixing in sRGB, so the head could come out a slightly different colour from the arc under it. Both now name in oklab, so any colour format leaves no seam.
At zero, a round end is a dot, which reads as a bug. The whole arc fades in over the first 2%, so a ring at zero is only its dim track.
Past 100%
The ring keeps going: progress has no ceiling. From the second lap on, the arc stays a full ring and the colour sweep turns with the head instead, so the head always carries the end colour and the start colour sits just ahead of it. The head then casts a shadow onto the ring underneath, on its leading side only. It is a radial-gradient that starts at the edge of the head and fades out over a quarter of the stroke, and the same ring mask keeps it off the gap between rings. It fades in between 90% and 100%, since before that there is nothing for it to fall on.
One number, all the way down
Each ring is a single Motion value, progress. Three useTransforms turn it into the sweep, the gradient's rotation and the head's angle, and write them to custom properties, so the stepper, the fill and the restart all set that one number and React never re-renders while it moves.
The fill is a spring of 1.2s with no bounce, because a progress value that overshoots and comes back reads as a bug. The rings start 0.12s apart, outermost first. Crossing 100% swells the ring to 1.03 and back over 0.4s, once, and only on the way up.
If you're about to build one
I would use this in a real app, with two things to know first.
It takes one to four rings. The stroke is a fixed share of the diameter, measured from Apple's rings, so a fifth ring closes the hole in the middle. The rings prop is typed as a tuple of one to four, so a fifth is a type error instead of a broken drawing.
The whole graphic is one image to a screen reader, so it needs an aria-label that says every value, such as "Move 84%, Exercise 73%, Stand 75%", and the component will not compile without one. With reduced motion turned on, every ring jumps straight to its value and the closing swell is skipped.
The code
ProgressRings.tsx
ProgressRings.module.css
geometry.ts
export const FULL_TURN = 360/*** Where a ring is drawn for a given progress, in degrees clockwise from 12 o'clock. Past 100% the* arc stays a full ring and the colour sweep turns with the head instead, so the head always* carries the end colour and the start colour sits just ahead of it, as on the watch.*/export function splitLaps(progress: number) {return {sweep: Math.min(progress, 1) * FULL_TURN,from: Math.max(progress - 1, 0) * FULL_TURN,head: progress * FULL_TURN,}}export function progressOf(value: number, goal: number) {return value / goal}export function percentOf(progress: number) {return Math.round(progress * 100)}
tuning.ts
References
- 1.Close your rings · Apple
- 2.conic-gradient() · CSS · MDN
- 3.mask · CSS · MDN
- 4.color-mix() · CSS · MDN