Progress
Communicate how far an operation has come — linear bar or circular ring, five semantic intents, five sizes, name/value/description anatomy, custom ranges with formatted announcements, segmented onboarding strips, on-color tone, indeterminate animation with reduced-motion substitutes, and LTR/RTL support.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Progress } from "@tessinaui/ui";{/* Determinate */}
<Progress value={65} aria-label="Upload" />
{/* Name + value header */}
<Progress value={65} label="Backing up" showLabel showValue />
{/* Custom range, announced as "64 of 250" (aria-valuetext) */}
<Progress value={64} max={250} showValue formatLabel={(v, m) => `${v} of ${m}`} label="Films" />
{/* Indeterminate — the label stays visible */}
<Progress indeterminate label="Contacting server…" showLabel />
{/* Circular with the value in the center */}
<Progress type="circular" value={65} size="lg" showValue aria-label="Sync" />
{/* Segmented onboarding strip — still ONE progressbar */}
<Progress segments={4} value={50} aria-label="Step 2 of 4" />When to use
- A task with measurable progress — uploads, downloads, imports, multi-step processing. Prefer determinate whenever the work is measurable (HIG), and switch indeterminate → determinate the moment it becomes so.
- Longer than ~5 seconds — shorter waits take a
Spinner; loading content of known shape takes aSkeleton(M3/Carbon thresholds). - Keep one bar per operation — combine steps into a single monotonic fill and change the
descriptionper step; a bar that rewinds destroys trust (Fluent/Carbon).
When not to use
- Point-in-time gauges — storage used, quota left, battery, scores. That is
Meter(role="meter"), including stacked parts-of-a-whole viaMeterGroup. A progressbar announces task completion; a meter states a measurement. - Labeled step journeys — numbered checkout/wizard steps with captions belong to
Stepper.segmentshere is purely visual track division. - Sub-second actions — show nothing under ~200ms.
- Forms don't automatically need one: GOV.UK ships question flows with a plain "Question 3 of 9" caption after finding step indicators unnoticed — test without one first.
Examples
Default
A basic linear progress bar showing a determinate value.
Types
Linear bar or circular ring via the type prop.
Intents
Five semantic intents — none (neutral primary), info, success, warning, and error.
Sizes
Five sizes — xs hairline through xl.
Labels and values
label names the operation (start-aligned); showValue renders the formatted value end-aligned in the header, or inside the fill on lg/xl.
Custom range and formatting
max re-bounds the range; formatLabel shapes both the visible value and the announced aria-valuetext.
Description and outcome states
A helper line under the bar carries status detail; intent="success" confirms completion, intent="error" + description explains failure and the way forward.
Segments
Split the track into discrete cells for onboarding strips — one accessible progressbar throughout.
On color
tone="on-color" derives track and fill from currentColor for coloured plates; intent colors intentionally give way to the surface ink.
Circular center content
The ring accepts children as its center — counts, icons, or a check on completion.
Indeterminate
Animated unknown-duration state for both types. Under prefers-reduced-motion the slide/spin is replaced by an opacity pulse — activity stays signalled without translation or rotation.
Upload list
Composition: per-file rows with waiting/uploading/paused/error/complete states, pause/cancel/retry controls, and a polite live region carrying the announcements a progressbar itself never makes.
Loading
Skeleton placeholders shown before a value is known — same box as the real component (§2b), label bar sized in characters.
API Reference
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
type | "linear" | "circular" | "linear" | Bar or ring |
value | number | 0 | Current progress, clamped to [0, max]; non-finite input renders empty |
max | number | 100 | Upper bound; max <= 0 falls back to 100 |
intent | "none" | "info" | "success" | "warning" | "error" | "none" | Semantic fill color; none renders the neutral primary fill |
tone | "default" | "on-color" | "default" | On coloured plates: monochrome currentColor track/fill |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Track height (linear) / ring diameter (circular) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Corner rounding of track, fill and segments (linear only) |
label | string | — | Accessible NAME of the operation; becomes aria-label |
showLabel | boolean | false | Render label visibly — above the bar / below the ring |
showValue | boolean | false | Render the formatted value — header end / ring center |
valuePosition | "end" | "inside" | "end" | Linear value placement; inside needs lg/xl |
formatLabel | (value, max) => string | rounded % | Formats the visible value AND aria-valuetext |
description | ReactNode | — | Helper line under the bar / ring — status text, error detail |
segments | number | — | Split the linear track into N cells; ignored while indeterminate |
indeterminate | boolean | false | Unknown-duration animation — value ignored, value labels suppressed |
children | ReactNode | — | Circular only: custom center content |
dir | "ltr" | "rtl" | "ltr" | RTL mirrors the linear fill; the ring never mirrors (spec) |
className | string | — | Additional classes on the root wrapper |
variant | — | — | Deprecated — use intent (default → none) |
labelPosition | — | — | Deprecated — use showValue + valuePosition; while present, the old value-display behaviour is kept |
ProgressSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
type / size / rounded | as Progress | — | Same geometry sources as the real component (§2b) |
showLabel | boolean | false | Reserve the header / under-ring label line |
labelChars | number | 8 | Characters of the real label — width-true text bar |
label | string | "Loading" | What AT announces while the placeholder is up — rendered as sr-only text, not aria-label |
Sizes
| Size | Linear height | Circular diameter |
|---|---|---|
xs | 4px (h-1) | 24px |
sm | 6px (h-1.5) | 32px |
md | 10px (h-2.5) | 48px |
lg | 16px (h-4) | 64px |
xl | 32px (h-8) | 128px |
Ring strokes are viewBox-proportional — they scale with the diameter.
DOM contract
Stable hooks for styling, tests and agents:
| Attribute | Where | Values |
|---|---|---|
data-slot | every part | progress, progress-header, progress-label, progress-value, progress-track, progress-ring, progress-fill, progress-segment, progress-description, progress-skeleton |
data-state | wrapper + control | loading · complete (value ≥ max) · indeterminate |
data-value / data-max | wrapper + control | current numbers; data-value absent while indeterminate |
Accessibility
Pattern: ARIA progressbar role. Not an interactive widget — no keyboard interaction is defined.
-
The role element carries
aria-valuenow/aria-valuemin/aria-valuemax;aria-valuenowis omitted entirely while indeterminate (never zeroed), and the fallback name becomes "Loading", never a phantom "0%". -
Name: pass
label(oraria-label/aria-labelledby— forwarded aria-* reaches the role element and outranks the fallback). AT announces the value as a percent of range;formatLabeloverrides that viaaria-valuetextwhen percent would mislead ("64 of 250"). -
A progressbar is not a live region (WCAG ARIA25): value updates are not announced by themselves. Pair it with a polite
role="status"element for spoken updates, and putaria-busy="true"+aria-describedbyon the region being loaded — the upload-list example shows the wiring. -
The wrapper/control split routes consumer
aria-*to the role element anddata-*/test ids to the role-less wrapper — bothtypes, verified by DOM-read tests. -
Reduced motion: indeterminate translation/rotation swaps to an opacity pulse; value transitions are disabled. Verified per engine with computed styles, not class presence.
-
Contrast (measured, both themes): fills pass 3:1 against track and page (WCAG 1.4.11) — except
intent="warning"in the light theme (2.17:1 vs page), the system-wide known light-warning token limitation; pair a warning bar with itsdescriptionrather than relying on hue. The on-color tone derives from the surface ink contract (§3). -
Skeleton announcement:
ProgressSkeletonis arole="status"region whose text issr-only, never anaria-label— a live region announces its CONTENT, so a label-only region is silent on insertion (VoiceOver reported the earlier version as "empty application status").
AT matrix: VoiceOver + Safari verified locally. NVDA and JAWS — UNTESTED (no Windows host; see QA §5.6 reality note). Status stays beta until an NVDA pass is recorded.
Notes
- Near-zero values: a rounded fill keeps a minimum started-dot width (one track-height) as soon as
value > 0, so 0.5% reads as "started", not as a squashed sliver — and0renders an empty track. - Inside value: only on
size="lg" | "xl"and never undertone="on-color"— it falls back to the header end position. - Ring center: content is capped to the square that fits inside the arc (
inner ⌀ / √2— 27px atmd, 35px atlg, 73px atxl) and clipped beyond it, so a long value can never paint over the stroke. Keep it to about four characters: a percentage, a count, an icon. Longer text belongs inlabelordescriptionbelow the ring —showValuewith aformatLabelfraction reads as "26%" inside and "64 of 250 films" underneath. - Small rings:
xs(24px) andsm(32px) leave 14–19px of usable square, which cannot hold legible type, soshowValuerenders the value under the ring at those sizes instead of shrinking it. Customchildrenstill render inside — that is where a check or a dot belongs. - Meter boundary: quota/storage/battery displays are measurements, not progress — use
Meter/MeterGroup(role="meter").