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.
pnpm add @tessinaui/uiWhen to use
- A task with measurable progress. Uploads, downloads, imports and
multi-step processing. Prefer determinate whenever the work is measurable, and
switch from
indeterminateto avaluethe moment it becomes so. - A wait long enough to need reassurance, roughly five seconds or more. Shorter waits take a Spinner.
- One bar per operation. Combine steps into a single monotonic fill and
change the
descriptionper step; a bar that rewinds destroys trust. - A visible value next to the bar when the number matters. Set
showValueand alabel, and useformatLabelwhen a count reads better than a percentage ("64 of 250"). - An unknown duration with
indeterminate, keeping thelabelvisible so the wait still says what is happening.
When not to use
- For a point-in-time measurement. Storage used, quota left, battery or a
score is a gauge, not a task. Use Meter, including
MeterGroupfor parts of a whole. A progressbar announces task completion; a meter states a measurement. - For a list of named, clickable steps. No step indicator ships yet.
segmentshere only divides the track visually, so name the current step in thelabel("Step 2 of 4: Shipping"). Stepper changes a number; it is not a step list either. - While content of a known shape loads. Use Skeleton.
- For a sub-second action. Show nothing under about 200ms, and a Spinner for a short wait.
- For a form that does not need it. A plain "Question 3 of 9" caption carries the same information, and step indicators are routinely missed in testing; try without one first.
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" />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.
Value positions
valuePosition picks where the value sits: end closes the header row, inside rides the fill on lg/xl, and below ends the caption row under the bar, beside the description. On a ring, below moves the value under it at every size.
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
Inside a Surface, Progress inherits tone="on-color": track and fill derive from currentColor, and intent colors intentionally give way to the surface ink. Pass tone="on-color" yourself only on a plate something else paints; an explicit tone always wins.
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.
RTL
Under dir="rtl" the header row mirrors, and a linear bar, its segments and the
indeterminate sweep run from the right. A circular ring does not mirror, because it turns clockwise in every locale.
Without dir, Progress inherits the page's direction.
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 |
completeIntent | ProgressIntent | false | "success" | What a neutral (intent="none") bar turns to when it reaches max. The fill takes that intent's colour and, beside a visible value, a check settles in. An explicit intent is never overridden; false keeps the fill as it was |
tone | "default" | "on-color" | inherited | On coloured plates: monochrome currentColor track/fill. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins |
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 — where valuePosition says |
valuePosition | "end" | "inside" | "below" | "end" | end closes the header row; below ends the caption row under the bar (under the ring at every size); inside needs lg/xl, and with segments sits in the cell holding the fill's edge |
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" | — | RTL mirrors the linear fill; the ring never mirrors (spec). Unset, it inherits the page's direction |
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 |
showValue / valuePosition | as Progress | false / "end" | Reserve the value's line — header end, caption row, or under the ring — so the swap is layout-shift-free |
label | string | "Loading", Arabic under dir="rtl" | 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-caption, 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; the fill's width and the counted figure jump to the new value, the label swaps at once and the completion check appears without its settle. The completion colour still eases (colour is not motion). Verified per engine with computed styles, not class presence.
-
Contrast (measured, both themes): three fills do not clear the 3:1 WCAG 1.4.11 asks against the
bg-secondarytrack:intent="warning"(1.7:1) andintent="success"(2.6:1) in the light theme, andintent="error"(2.8:1) in dark — and light-theme amber reads 2.2:1 against the page. That is the intent palette's own ceiling, accepted design-system-wide (§9, TES-272), not a Progress tuning miss, so a bar in one of those intents carries its state in words:showValuefor the number and adescriptionfor what the intent means, rather than the fill's extent and hue. Every other fill clears 3:1. The on-color tone derives from the surface ink contract (§3). -
Skeleton announcement:
ProgressSkeletonis arole="status"region whose text issr-only, never anaria-label, and the text is written 150ms after the region mounts, with noaria-busyon it. VoiceOver + Safari does not speak a status region inserted with its text already in place, or one marked busy, so both were silent until TES-257.
AT matrix: VoiceOver + Safari, 2026-09-14. The bar passed: it reads "65%, progress indicator". The skeleton failed on insertion (nothing spoken when it mounted, although a control alert was heard) and passed after the fix: "Loading" is spoken as it appears. NVDA and JAWS: UNTESTED (no Windows host; see QA §5.6 reality note).
Motion
- One spring drives the fill and the figure. The fill's width runs on the panel curve (a critically damped spring, no overshoot, about half a second). The visible number counts on that same curve, so it never reads ahead of or behind the bar.
aria-valuenowandaria-valuetextalways hold the real value; only what is drawn counts. - Completion. A neutral bar that reaches
maxturns to thecompleteIntentcolour on the same clock as the fill, and a check settles in beside the value. A bar with its ownintentkeeps it. - A changing label crosses over. When
labelchanges with the work ("Uploading photos" to "Almost there"), the new text fades in as the old fades out in the same line; the outgoing copy is hidden from assistive tech and is gone once its fade has run. - Retargeting is continuous. A new
valuemid-flight starts from where the bar and the figure are, so dragging a value never snaps.
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. Withsegmentsit sits in the cell holding the fill's leading edge (cell one at0), and only that cell grows if the value would not fit its equal share. The fill never gets narrower than its own value, so at low values it runs a little ahead of the true extent ("3%" needs about 36px atlg) rather than clipping the number. At exactly0no fill is painted and the value sits on the track in the foreground colour. - 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,smandmdleave 14–27px of usable square, which cannot hold the library's 12px type floor, soshowValuerenders the value under the ring at those sizes instead of shrinking it. Fromlgup it sits in the centre. 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").