Experiments

Progress Rings

Concentric progress rings drawn with CSS gradients and masks, each one driven by a single number

October 16, 2026

  • Move420/500CAL

  • Exercise22/30MIN

  • Stand9/12HRS

Push a ring past its goal to close it

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

"use client"
import {
animate,
type MotionStyle,
type MotionValue,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react"
import type React from "react"
import { useEffect, useRef } from "react"
import { cn } from "@/src/utils"
import { splitLaps } from "./geometry"
import styles from "./ProgressRings.module.css"
import { useProgressRingTuning } from "./tuning"
export interface Ring {
/** 1 is the goal. There is no ceiling: past 1 the ring keeps lapping */
progress: number
/** the colour at 12 o'clock, where the sweep starts */
startColor: string
/** the colour a full lap ends on, and the head's colour from the second lap on */
endColor: string
}
/**
* One to four rings, outermost first. The stroke is a fixed share of the diameter, measured from
* Apple's rings, so a fifth would close the hole in the middle; the tuple turns that into a type
* error instead of a broken drawing.
*/
export type Rings = [Ring] | [Ring, Ring] | [Ring, Ring, Ring] | [Ring, Ring, Ring, Ring]
interface ProgressRingsProps extends React.ComponentPropsWithRef<"div"> {
/** the graphic is one image to a screen reader, so it has to say every ring's value itself */
"aria-label": string
rings: Rings
}
/**
* Concentric progress rings, Apple Watch style. A ring's place in `rings` sets its radius and when
* it starts to fill. The diameter is the `--size` custom property, 280px unless a `className` sets
* it.
*/
export function ProgressRings({ rings, className, ...props }: ProgressRingsProps) {
return (
<div role={"img"} className={cn(styles.rings, className)} {...props}>
{rings.map((ring, index) => (
// a ring is its position: the radius and the stagger both come from the index
// biome-ignore lint/suspicious/noArrayIndexKey: rings never reorder, only grow or shrink at the end
<RingLayer key={index} index={index} {...ring} />
))}
</div>
)
}
interface RingLayerProps extends Ring {
index: number
}
/**
* One ring. `progress` is the only input, animated on a spring, and the arc, the colour sweep, the
* head and its shadow are all derived from it.
*/
function RingLayer({ index, progress: target, startColor, endColor }: RingLayerProps) {
const { fill, stagger, pulse, shadow, trackOpacity } = useProgressRingTuning()
const reduced = useReducedMotion()
const progress = useMotionValue(0)
const scale = useMotionValue(1)
useRingFill({ progress, target, delay: index * stagger, spring: fill, reduced })
useClosingPulse({ progress, scale, pulse, reduced })
const sweep = useTransform(progress, (value) => `${splitLaps(value).sweep}deg`)
const from = useTransform(progress, (value) => `${splitLaps(value).from}deg`)
const head = useTransform(progress, (value) => `${splitLaps(value).head}deg`)
return (
<motion.div
aria-hidden={"true"}
className={styles.ring}
style={
{
scale,
"--index": index,
"--start": startColor,
"--end": endColor,
"--progress": progress,
"--sweep": sweep,
"--from": from,
"--head": head,
"--track-opacity": trackOpacity,
"--shadow-opacity": shadow.opacity,
"--shadow-spread": shadow.spread,
} as MotionStyle
}
>
<div className={styles.track} />
<div className={styles.fill}>
<div className={styles.startCap} />
<div className={styles.arc} />
<div className={styles.head}>
<div className={styles.shadow} />
<div className={styles.headCap} />
</div>
</div>
</motion.div>
)
}
interface RingFillOptions {
progress: MotionValue<number>
target: number
delay: number
spring: { duration: number; bounce: number }
reduced: boolean | null
}
/** the stagger belongs to the first fill only; every later change answers at once */
function useRingFill({ progress, target, delay, spring, reduced }: RingFillOptions) {
const hasStarted = useRef(false)
const { duration, bounce } = spring
useEffect(() => {
const controls = animate(
progress,
target,
reduced
? { duration: 0 }
: {
type: "spring",
duration,
bounce,
delay: hasStarted.current ? 0 : delay,
// set on the first frame rather than on start, so StrictMode's throwaway run
// does not spend the delay
onUpdate: () => {
hasStarted.current = true
},
},
)
return () => controls.stop()
}, [progress, target, delay, duration, bounce, reduced])
}
interface ClosingPulseOptions {
progress: MotionValue<number>
scale: MotionValue<number>
pulse: { duration: number; scale: number }
reduced: boolean | null
}
function useClosingPulse({ progress, scale, pulse, reduced }: ClosingPulseOptions) {
useEffect(() => {
if (reduced) return
let previous = progress.get()
return progress.on("change", (latest) => {
const hasClosed = previous < 1 && latest >= 1
previous = latest
if (!hasClosed) return
animate(scale, [1, pulse.scale, 1], {
duration: pulse.duration,
times: [0, 0.35, 1],
ease: "easeOut",
})
})
}, [progress, scale, pulse.duration, pulse.scale, reduced])
}

ProgressRings.module.css

/* geometry as fractions of the diameter, measured from a 1064px capture of the real rings. The
diameter falls back to 280px, so a consumer sets --size on the root rather than overriding it */
.rings {
--stroke: calc(var(--size, 280px) * 0.096);
--gap: calc(var(--size, 280px) * 0.0085);
position: relative;
width: var(--size, 280px);
height: var(--size, 280px);
flex-shrink: 0;
}
/* every layer of a ring is cut to its annulus here, which is also what keeps the head's shadow on
the ring rather than in the gap. The half-pixel ramps are the anti-aliasing */
.ring {
position: absolute;
inset: calc(var(--index) * (var(--stroke) + var(--gap)));
mask: radial-gradient(
closest-side,
transparent calc(100% - var(--stroke) - 0.5px),
#000 calc(100% - var(--stroke) + 0.5px),
#000 calc(100% - 0.5px),
transparent 100%
);
will-change: transform;
}
.ring > *,
.fill > *,
.head {
position: absolute;
inset: 0;
}
/* the sweep is tied to the angle, not to the arc, so a short arc shows only the start of it */
.track {
background: conic-gradient(in oklab, var(--start), var(--end));
opacity: var(--track-opacity);
}
/* a round cap at zero is a dot, so the arc and both caps fade in over the first 2% */
.fill {
opacity: clamp(0, var(--progress) * 50, 1);
}
.arc {
background: conic-gradient(from var(--from) in oklab, var(--start), var(--end));
mask: conic-gradient(#000 var(--sweep), transparent 0);
}
.startCap,
.headCap {
position: absolute;
top: 0;
left: calc(50% - var(--stroke) / 2);
width: var(--stroke);
height: var(--stroke);
border-radius: 50%;
}
.startCap {
background: var(--start);
}
.head {
rotate: var(--head);
}
/* the colour the sweep has under the head, and the end colour from the second lap on. Mixed in the
same space the gradients interpolate in, named on both so any colour format leaves no seam */
.headCap {
background: color-mix(in oklab, var(--end) clamp(0%, var(--progress) * 100%, 100%), var(--start));
}
/* only the leading half: the trailing side is the head's own arc, which casts nothing on itself.
It comes in as the head reaches the start of the ring, the only thing it can fall on */
.shadow {
position: absolute;
top: 0;
left: 50%;
width: calc(var(--stroke) * (0.5 + var(--shadow-spread)));
height: var(--stroke);
background: radial-gradient(
circle at 0 50%,
rgb(0 0 0 / var(--shadow-opacity)) calc(var(--stroke) / 2 - 1px),
rgb(0 0 0 / calc(var(--shadow-opacity) * 0.35))
calc(var(--stroke) * (0.5 + var(--shadow-spread) * 0.4)),
transparent calc(var(--stroke) * (0.5 + var(--shadow-spread)))
);
opacity: clamp(0, (var(--progress) - 0.9) * 10, 1);
}

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

"use client"
import { createContext, useContext } from "react"
/**
* The numbers the rings run on, keyed by the move. The published component reads these defaults;
* the fill spring is also the demo's own dials, and `/lab` replaces the rest with DialKit.
*/
export interface ProgressRingTuning {
/** a ring travelling to its value; no bounce, since a progress that overshoots reads as a bug */
fill: { duration: number; bounce: number }
/** the delay between one ring starting to fill and the next, outer to inner */
stagger: number
/** the ring swelling once as it crosses 100% */
pulse: { duration: number; scale: number }
/** the head's shadow on the start of the ring, as a fraction of the stroke for the spread */
shadow: { opacity: number; spread: number }
/** the dim ring behind the arc */
trackOpacity: number
}
export const PROGRESS_RING_TUNING: ProgressRingTuning = {
fill: { duration: 1.2, bounce: 0 },
stagger: 0.12,
pulse: { duration: 0.4, scale: 1.03 },
shadow: { opacity: 0.55, spread: 0.25 },
trackOpacity: 0.22,
}
const ProgressRingTuningContext = createContext(PROGRESS_RING_TUNING)
export const ProgressRingTuningProvider = ProgressRingTuningContext.Provider
export function useProgressRingTuning() {
return useContext(ProgressRingTuningContext)
}

References

  1. 1.Close your rings · Apple
  2. 2.conic-gradient() · CSS · MDN
  3. 3.mask · CSS · MDN
  4. 4.color-mix() · CSS · MDN

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

Map Sheet

A glass sheet over a map that drags between three heights, with everything behind it following the drag