DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. Progress

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/ui

When to use

  • A task with measurable progress. Uploads, downloads, imports and multi-step processing. Prefer determinate whenever the work is measurable, and switch from indeterminate to a value the 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 description per step; a bar that rewinds destroys trust.
  • A visible value next to the bar when the number matters. Set showValue and a label, and use formatLabel when a count reads better than a percentage ("64 of 250").
  • An unknown duration with indeterminate, keeping the label visible 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 MeterGroup for 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. segments here only divides the track visually, so name the current step in the label ("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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Progress } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDefaultDescription
type"linear" | "circular""linear"Bar or ring
valuenumber0Current progress, clamped to [0, max]; non-finite input renders empty
maxnumber100Upper bound; max <= 0 falls back to 100
intent"none" | "info" | "success" | "warning" | "error""none"Semantic fill color; none renders the neutral primary fill
completeIntentProgressIntent | 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"inheritedOn 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)
labelstring—Accessible NAME of the operation; becomes aria-label
showLabelbooleanfalseRender label visibly — above the bar / below the ring
showValuebooleanfalseRender 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) => stringrounded %Formats the visible value AND aria-valuetext
descriptionReactNode—Helper line under the bar / ring — status text, error detail
segmentsnumber—Split the linear track into N cells; ignored while indeterminate
indeterminatebooleanfalseUnknown-duration animation — value ignored, value labels suppressed
childrenReactNode—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
classNamestring—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

PropTypeDefaultDescription
type / size / roundedas Progress—Same geometry sources as the real component (§2b)
showLabelbooleanfalseReserve the header / under-ring label line
labelCharsnumber8Characters of the real label — width-true text bar
showValue / valuePositionas Progressfalse / "end"Reserve the value's line — header end, caption row, or under the ring — so the swap is layout-shift-free
labelstring"Loading", Arabic under dir="rtl"What AT announces while the placeholder is up — rendered as sr-only text, not aria-label

Sizes

SizeLinear heightCircular diameter
xs4px (h-1)24px
sm6px (h-1.5)32px
md10px (h-2.5)48px
lg16px (h-4)64px
xl32px (h-8)128px

Ring strokes are viewBox-proportional — they scale with the diameter.

DOM contract

Stable hooks for styling, tests and agents:

AttributeWhereValues
data-slotevery partprogress, progress-header, progress-label, progress-value, progress-track, progress-caption, progress-ring, progress-fill, progress-segment, progress-description, progress-skeleton
data-statewrapper + controlloading · complete (value ≥ max) · indeterminate
data-value / data-maxwrapper + controlcurrent 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-valuenow is omitted entirely while indeterminate (never zeroed), and the fallback name becomes "Loading", never a phantom "0%".

  • Name: pass label (or aria-label/aria-labelledby — forwarded aria-* reaches the role element and outranks the fallback). AT announces the value as a percent of range; formatLabel overrides that via aria-valuetext when 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 put aria-busy="true" + aria-describedby on the region being loaded — the upload-list example shows the wiring.

  • The wrapper/control split routes consumer aria-* to the role element and data-*/test ids to the role-less wrapper — both types, 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-secondary track: intent="warning" (1.7:1) and intent="success" (2.6:1) in the light theme, and intent="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: showValue for the number and a description for 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: ProgressSkeleton is a role="status" region whose text is sr-only, never an aria-label, and the text is written 150ms after the region mounts, with no aria-busy on 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-valuenow and aria-valuetext always hold the real value; only what is drawn counts.
  • Completion. A neutral bar that reaches max turns to the completeIntent colour on the same clock as the fill, and a check settles in beside the value. A bar with its own intent keeps it.
  • A changing label crosses over. When label changes 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 value mid-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 — and 0 renders an empty track.
  • Inside value: only on size="lg" | "xl" and never under tone="on-color" — it falls back to the header end position. With segments it sits in the cell holding the fill's leading edge (cell one at 0), 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 at lg) rather than clipping the number. At exactly 0 no 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 at md, 35px at lg, 73px at xl) 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 in label or description below the ring — showValue with a formatLabel fraction reads as "26%" inside and "64 of 250 films" underneath.
  • Small rings: xs, sm and md leave 14–27px of usable square, which cannot hold the library's 12px type floor, so showValue renders the value under the ring at those sizes instead of shrinking it. From lg up it sits in the centre. Custom children still 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").
SkeletonMeter

On this page

When to useWhen not to useInstallationUsageExamplesDefaultTypesIntentsSizesLabels and valuesValue positionsCustom range and formattingDescription and outcome statesSegmentsOn colorCircular center contentIndeterminateUpload listLoadingRTLAPI ReferenceProgressProgressSkeletonSizesDOM contractAccessibilityMotionNotes