Progress Ring

A circular progress ring whose value springs to its target, so a number that changes mid travel keeps the speed it already had.

Loading preview

Installation

npx shadcn@latest add @loomui/progress-ring

Usage

import { ProgressRing } from "@/components/ui/progress-ring"
<ProgressRing value={64} label="Threads wound" />

Give it a label whenever the ring is the only thing saying what it measures. It renders as a real progressbar, so the number is announced whether or not anyone can see it.

Why it springs

Most progress rings tween. Set a new value halfway through the last one and the ring stops dead, then starts again from nothing. On a number that updates while you watch, that reads as stuttering rather than progress.

This one springs. A target that lands mid travel keeps the speed the ring already had, so it bends toward the new number instead of restarting.

<ProgressRing value={value} spring={{ duration: 0.6, bounce: 0 }} />

duration is roughly how long it settles. bounce overshoots past the target and comes back. It is 0 by default, because a ring that sails past 90 and returns is lying about the number for a moment.

The one paint

The arc is drawn by walking the stroke's own dash offset around the circle. That is a paint rather than a composite, which is the one place this steps outside transform and opacity.

There is no way to draw a partial arc without it. It stays cheap because only the stroke is repainted, and nothing about it touches layout.

The value is written straight onto the node and the number onto its text, so the ring animates without a single re-render.

Size and weight

<ProgressRing value={72} size={180} thickness={14} />

size is the outside diameter and thickness is the stroke, both in pixels. Everything else is worked out from them, so the ring stays in proportion at any size and the number in the middle scales with it.

Something other than a number

<ProgressRing value={38} center={<Icon />} />

center replaces the number entirely. Pass showValue={false} to have neither and keep just the ring.

Scales and formats

<ProgressRing value={7} max={9} format={(v) => `${v.toFixed(1)}s`} />

max is what a full ring means. format turns the animating value into the text in the middle, so it can count in seconds, bytes or anything else while the ring fills.

Props

PropTypeDefaultDescription
valuenumbernoneWhere the ring is now.
maxnumber100What a full ring means.
sizenumber120Outside diameter in pixels.
thicknessnumber10Stroke width in pixels.
springSpringOptions.6/0How the value travels.
showValuebooleantruePrint the number in the middle.
format(value: number) => stringnoneTurns the value into the middle text.
centerReactNodenoneSits in the middle instead of a number.
labelstringnoneAccessible name.
disabledbooleanfalseRender at the value without animating.

Reduced motion

With reduced motion the ring is drawn at its value straight away, and every later change lands the same way. The number is still correct, it just never travels to get there.