Composites
Circular Progress
Progress as a ring, for the places a bar does not fit: next to a file that is uploading, in the corner of an onboarding card, as the score of a checklist or an OKR. It spins when the total is not known yet.
- Import
@comitor/ui— the main entry. No optional peer to install.- Runtime
- A client component. A Server Component can render it with plain values;
getValueTextis a function, so a ring that needs one belongs in a client component. The spinning state needs the package’sstyles.css— see Known limitations. - Origin
- Ported in
2.1.0from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own tokens — chosen over Dice UI’s Gauge, which runs on the same engine. Every deviation from upstream is listed in the changelog.
Anatomy
A root that does the arithmetic and the ARIA, an svg with two circles, and the text in the middle. Reach for CircularProgressCombined when the standard ring is what you want, and for the parts the moment you need to change one of them.
import {
CircularProgress,
CircularProgressCombined,
CircularProgressIndicator,
CircularProgressRange,
CircularProgressTrack,
CircularProgressValueText,
} from '@comitor/ui'
<CircularProgress value={65} label="Uploading"> // role="progressbar"
<CircularProgressIndicator> // <svg>, turned so the arc starts at 12 o'clock
<CircularProgressTrack /> // the full ring, the tone's soft tint
<CircularProgressRange /> // the arc, the tone's own colour
</CircularProgressIndicator>
<CircularProgressValueText /> // "65%", centred on the ring
</CircularProgress>
// …or exactly that assembly in one element:
<CircularProgressCombined value={65} label="Uploading" />An upload in progress
A timer stands in for the upload’s progress events. The ring is named by the file name beside it through aria-labelledby, turns green at 100% through a data-[state=complete]: class on the range, and swaps the “100%” for a check mark — on screen only: the value text still says 100% to a screen reader.
Q3-board-deck.pdf
0.0 of 4.2 MB
Completion of a checklist
Nothing says the scale is a percentage. max is the number of steps and value the number done; getValueText turns that into words for a screen reader (“2 of 6 done”), while the ring shows the compact “2/6”. Tick the boxes.
Onboarding checklist
4 left before Lê Minh Châu starts on Monday
When the total is unknown
value={null} — or no value at all — is the indeterminate state: data-state="indeterminate", no aria-valuenow, no value text, and a quarter arc that goes round once every 0.8 seconds. The spin is --animate-spin-around from the package’s styles.css, applied only under motion-safe:. The right-hand ring is the same component with the animation taken off, to show what the other case looks like.
That still quarter arc is a trap. It is what a user with reduced motion sees, and what everybody sees when the app’s stylesheet lacks the keyframes — and it looks exactly like a real value of 25%. No error, no warning. Always put words next to an indeterminate ring (the label here), so it reads as “preparing”, not “a quarter done”.
Sizes
size is the diameter and thickness the stroke, both in px, and the stroke keeps its width at any size. The value text does not scale with the ring — it is text-sm until you say otherwise — so give the bigger rings a bigger class, and drop it from the small ones, where the sentence beside the ring says the number.
Colour
Pass a tone — the same seven as Status Pill: default (the brand gold), neutral, success, warning, severe, destructive and info. The arc is the tone's own colour — the fill step, not the darker ink — and the track a soft tint of the same tone.
- 62%
Q3 OKR · Ship mobile check-in
On track
- 30%
Onboarding checklist
Not started this week
- 100%
Nightly backup
Finished at 02:14
- 88%
Storage · Marketing
Nearly full
- 93%
API quota · Partners
Throttling at 95%
- 97%
Storage · Engineering
Over 95% — uploads are paused
- 45%
Import · CRM contacts
Running, 12 min left
Keeping the track grey
Set track="neutral" and the track stays the upstream grey, text-muted-foreground/20, while the arc keeps its tone. Use it where several rings of different tones sit side by side and only the arcs should carry colour.
- 62%
Q3 OKR · Ship mobile check-in
On track
- 30%
Onboarding checklist
Not started this week
- 100%
Nightly backup
Finished at 02:14
- 88%
Storage · Marketing
Nearly full
- 93%
API quota · Partners
Throttling at 95%
- 97%
Storage · Engineering
Over 95% — uploads are paused
- 45%
Import · CRM contacts
Running, 12 min left
Colour cannot be the only signal: the words under each ring carry the status, and the number in the middle carries the value. That matters here, because the fill step is light. Arc against its own track, worst case on --muted, light · dark:
| Tone | Arc ⇄ tinted track (light · dark) |
|---|---|
| default | 1.50 · 8.39 |
| neutral | 2.90 · 3.25 |
| success | 2.19 · 3.85 |
| warning | 1.11 · 6.31 |
| severe | 2.49 · 4.68 |
| destructive | 3.15 · 3.03 |
| info | 2.88 · 3.07 |
- In light mode most arcs sit under the 3:1 WCAG 1.4.11 asks of a graphic — yellow lowest, at 1.11:1. That is an accepted trade-off: the ink step read as too heavy on a 4px stroke, and the arc is never the only place the value lives. Each pair is recorded, with its numbers, as an exemption in the package's contrast gate. In dark mode the ink step already is the fill colour, so nothing changes there.
- Do not let the ring be the only progress signal: keep
CircularProgressValueTextor a label beside it. - The track against the page is only about 1.1–1.6:1, tinted or grey. Deliberately: no pair of colours is both 3:1 apart and 3:1 from a white page. The package records each track as a measured exemption in its contrast gate.
- Unlike the Progress bar, the ring needs no outline to mark 100%: a circle closes on itself and the arc always starts at 12 o’clock, so the angle reads as a proportion without seeing the track. One unbroken stroke, too, so an alpha colour shows no seams where strokes cross.
Circular Progress or Progress?
Same job, different room. The ring is for a small square; the bar is for a line.
| Circular Progress | Progress | |
|---|---|---|
| Shape | A ring in a square — size sets the diameter | A bar, as wide as its container |
| Fits | A card corner, a table cell, beside a file name; a completion score | A form step, a page-wide job, anything read left to right |
| The number | Inside the ring (ValueText) | Beside the bar, placed by you |
| Unknown total | Spins — package keyframes, motion-safe only | Parks at 0; the package ships no animation |
| Tone | The tone prop, seven tones; track="neutral" keeps the grey track | The tone prop, four tones; warning adds hazard stripes |
| Accessible name | label (visible, under the ring) or aria-label | aria-label or aria-labelledby |
| Built on | Its own SVG with role="progressbar" | Radix Progress |
When there is nothing to measure at all — a button saving, a panel loading — neither is right: that is a Spinner.
What the port guarantees
Behaviour you can rely on that the upstream component did not give you.
- The arc and track follow a tone. Upstream painted the arc
text-primary, the fill token — bright gold, 1.86:1 on white — over a fixed grey track. Here both follow atone, withtrack="neutral"to keep the grey. - Every part has a
data-slot—circular-progress,-indicator,-track,-range,-value-text, plus-ringand-labelwhen a label is set — so you can style or test a part without relying on its classes. labelsits under the ring. Upstream placed it inside the root next to the ring, while the value text covered the whole root — so “65%” printed on top of the label. With a label, the ring now gets its own box and the value text centres on the ring; without one, the DOM is the same as upstream’s.- It renders without a bundler. Upstream read
process.env.NODE_ENVduring render; the package ships plain ES modules, and in a browser with no bundler to replace that expression it threw a ReferenceError. The check is guarded now — you lose the development warnings, not the ring.
Language
No namespace: the component ships no strings, so there is nothing for ComitorLocaleProvider to switch. Every word on it is yours — label, aria-label, and the value text. That last one is the catch: the default is Math.round(percentage) + '%' in every language, which is right for English and Vietnamese and wrong for French (“65 %”) or Turkish (“%65”). When your app serves those, build getValueText on Intl with the provider’s locale — set once at the root, as Get started shows.
'use client' // getValueText is a function: it cannot cross from a Server Component
import { CircularProgressCombined, useComitorLocale } from '@comitor/ui'
export function Upload({ value }: { value: number }) {
// The default text is Math.round(percentage) + '%' in every language.
// French wants "65 %", Turkish "%65". Intl knows; the component does not.
const { locale } = useComitorLocale()
const percent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 })
return (
<CircularProgressCombined
value={value}
label="Uploading"
getValueText={(current, min, max) => percent.format((current - min) / (max - min))}
/>
)
}Props
CircularProgress — and CircularProgressCombined, which takes the same props.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | undefined | null | The current value. null or undefined is indeterminate: the arc spins and aria-valuenow is dropped. A number outside min…max is clamped, with a console error in development. |
min | number | 0 | The lower bound. A non-finite value falls back to 0. |
max | number | 100 | Must be above 0, or it falls back to 100 with a console error in development. If it is not above min, it becomes min + 1. |
getValueText | (value: number, min: number, max: number) => string | rounded percentage | Builds aria-valuetext and the text in the middle of the ring — “45%” by default. Not locale-formatted: see Language. |
size | number | 48 | Diameter in px. Sets the svg's width, height and viewBox. |
thickness | number | 4 | Stroke width in px, kept constant at any size (non-scaling stroke). Must be less than size — a development warning says so. |
label | string | — | A visible label under the ring, which is also the progressbar's accessible name (aria-labelledby). Without it, pass aria-label or aria-labelledby: the package does not invent a name. |
tone | "default" | "neutral" | "success" | "warning" | "severe" | "destructive" | "info" | "default" | Colours the arc with the tone's own colour and the track with a soft tint of it — the seven tones of StatusPill; default is the brand gold. |
track | "tone" | "neutral" | "tone" | neutral keeps a grey track (text-muted-foreground/20), so only the arc carries the tone. |
aria-label / aria-labelledby | string | — | Spread after the built-in wiring, so either wins over label. |
asChild | boolean | false | Render onto your own element through Radix Slot, which takes exactly one child — so not together with label, which adds a second. |
...rest | React.ComponentProps<"div"> | — | Forwarded to the root. The root also carries data-state (loading, complete or indeterminate), data-value, data-min, data-max and data-percentage — a 0–1 fraction, not 0–100. |
The parts. Each also takes every native prop of its element; the default column is the class or content it starts with.
| Prop | Type | Default | Description |
|---|---|---|---|
CircularProgressCombined | CircularProgressProps | — | Every prop of CircularProgress; renders Indicator (Track + Range) and ValueText for you. Its children are ignored — use the parts to change any of them. |
CircularProgressIndicator | React.ComponentProps<"svg"> | — | The svg. Turned −90° so the arc starts at 12 o'clock; aria-hidden and not focusable. |
CircularProgressTrack | React.ComponentProps<"circle"> | tone tint | The full ring, in the tone's soft tint, or text-muted-foreground/20 when track="neutral". className still overrides it. |
CircularProgressRange | React.ComponentProps<"circle"> | tone colour | The arc, in the tone's own colour. It carries data-state, so data-[state=complete]: variants work. Value changes glide over 300 ms. |
CircularProgressValueText | React.ComponentProps<"span"> + asChild | getValueText result | Centred over the ring, text-sm font-medium. children replace the text on screen only; aria-valuetext still comes from getValueText. Empty while indeterminate. |
Known limitations
The first is recorded in the package’s own notes; the rest follow from the source. Each with its way round.
- Without the package CSS, “indeterminate” reads as 25%. The spin lives in
styles.css(--animate-spin-aroundand@keyframes spin-around, new in 2.1.0). An app that builds its own Tailwind theme without importing it gets a still quarter arc — no error, no warning. Import@comitor/ui/styles.cssaftertailwindcss, as Get started does. - Reduced motion shows the same still arc. Correct — nothing should spin — but it needs words beside it: a
label, or a sentence. - The value text is not locale-formatted. Pass
getValueTextbuilt onIntl.NumberFormat, as in Language. - Value changes always glide. The arc eases over 300 ms, and that transition is not switched off by
prefers-reduced-motion. Addmotion-reduce:transition-noneto the range if the ring should jump. asChildandlabeldo not mix. The label adds a second child, and a Slot takes one. Use one or the other.
Accessibility
- The root is
role="progressbar"witharia-valuemin,aria-valuemax,aria-valuenow(dropped while indeterminate) andaria-valuetextfromgetValueText. The svg isaria-hiddenand not focusable. - It needs a name, and it will not make one up: an unnamed progressbar is an ARIA 1.2 error. Use
labelfor a visible one under the ring, oraria-labelledbyto point at text already on screen (the file name in the upload demo), oraria-label. - It is not a live region. Screen readers treat a moving progressbar unevenly — some beep, some stay silent until the user reaches it — so for a long job, announce the milestones that matter (started, finished, failed) in a polite live region of your own.
- The ring is not interactive and takes no focus. Put the Pause and Cancel buttons beside it, not in it.
- The arc against the page is the information (WCAG 1.4.11). On the high-contrast palette it measures 6.51:1 light and 12.67:1 dark; on the default light palette it is the brand gold at 1.86:1, a recorded trade-off — which is why the value text in the middle matters.
- A tone is never the only signal: warning and brand gold look alike on three of four palettes. Say the status in words.
- The spin runs only under
motion-safe:. Withprefers-reduced-motionthe indeterminate arc stands still — pair it with words.
Related
- Progress — the bar, for a line rather than a square.
- Spinner — when there is nothing to measure.
- Tasks pattern — checklist and project progress, drawn with the bar today; Mobile pattern — onboarding and uploads on a small screen, where a ring saves a line.
- Upload — the uploader whose progress events a ring like the first demo would show.