← All stories

COMPONENT · ui-progress-bar

ui-progress-bar

Component Tier 1 (primitive) Used by file uploader, async job tracker, group invite, asset upload

A progress bar primitive — determinate when progress is known, indeterminate when not, never going backwards (because users panic when bars regress), respects prefers-reduced-motion, and never shows 100% before the operation actually completes.

Component contract

Renders a progress indicator. Determinate vs indeterminate is driven by whether value is provided.

  • value?: number — 0..100. When undefined, renders indeterminate.
  • label: string — required. Describes what's progressing ("Uploading address-book.csv"). Used as aria-label and visible text below the bar.
  • showLabel?: boolean — defaults true; even when false, label is still aria-label.
  • showValue?: boolean — when determinate, show "{value}%" inside or beside the bar. Defaults to true.
  • tone?: "default" | "success" | "warning" | "danger" — visual tone; "success" used when finalizing.
  • striped?: boolean — animated stripes during indeterminate; respects prefers-reduced-motion.

Interaction surface

  1. Determinate bar fills 0..100.

    Bar fill width = value%. Smooth transition between value updates (200ms ease-out). aria-valuenow updates accordingly.

  2. Indeterminate bar animates a sliding fill.

    When value is undefined, the bar shows a sliding/pulsing motion. aria-valuenow is omitted (or the bar uses aria-busy=true).

  3. Value clamps to 0..100.

    value=-5 clamps to 0; value=150 clamps to 100; value=NaN renders as indeterminate. Component never panics on bad input; it normalizes silently.

  4. Value never decreases.

    If a parent reports value=80 then value=60, the visual bar stays at 80 (the highest value seen). Receding progress is a contract bug; the component shields the UI from it.

  5. prefers-reduced-motion disables stripes + smooth transitions.

    Under reduce, indeterminate uses a static "in progress" indicator (e.g., a pulsing dot, but with prefers-reduced-motion the pulse is disabled too — falls back to a static "Working" text). Determinate transitions are instant.

Failure modes

Bar reaches 100% but operation isn't done

Trigger: parent reports value=100 prematurely; bar shows full + green; user thinks done but the artifact isn't ready.

The component cannot know "done" — that's the parent's responsibility. But the parent contract (enforced by ui-async-job-tracker) is to clamp at 99 until the terminal state. Soft contract, documented at branch level.

Bar regresses backwards

Trigger: parent's progress estimator gives 80 then 60; user sees the bar shrink and panics.

Component tracks max-seen value internally; bar visual never shrinks. Harness: dispatch value=80 then value=60, assert visual width corresponds to 80 (use computed-style or aria-valuenow).

Negative or NaN value crashes

Trigger: parent passes value=-5 or value=NaN, component throws.

Negative clamps to 0; NaN renders as indeterminate; over-100 clamps to 100. Harness: each input rendered without crash, value normalized.

Indeterminate bar pulses under prefers-reduced-motion

Trigger: motion-reduce active but the indeterminate animation still plays.

Under reduce, indeterminate falls back to a static "Working" text or a non-animated dot. Harness: emulate motion-reduce, indeterminate render, assert no animation property in computed style.

Label is hidden by overflow

Trigger: long label like "Uploading address-book-very-long-filename-2026-04-28-final-revised.csv" gets cut off.

Label truncates with ellipsis; full label is the bar's title attribute (or aria-label) so it's accessible. Harness: long label, assert visible text overflows + ellipsis OR is clipped, and aria-label contains full text.

aria-valuenow not updating in real time

Trigger: bar visual transitions smoothly but aria-valuenow updates only at the end of the transition.

aria-valuenow updates immediately when value prop changes (matching the destination, not the in-flight visual position). Harness: dispatch value=50, assert aria-valuenow=50 within next render frame.

Visible value text out of sync with bar

Trigger: bar smoothly animates from 50 to 80, but the "%" text jumps instantly to 80 — looks misaligned.

"%" text either tweens to match the bar OR snaps to the destination value. Either is acceptable; mixed (text snaps, bar tweens) is not. Harness: dispatch value=80 from value=50, screenshot mid-transition (~200ms), assert the displayed text matches or is between the two endpoints.

Color tone alone signals state to a colorblind user

Trigger: tone=danger renders a red bar with no other indicator; user can't tell it's an error condition.

Tone is reinforced by an icon (warning triangle for warning, X for danger) AND the label text reflecting the state. Color is not the only differentiator. Harness: render tone=danger, screenshot under grayscale filter, assert state still discernible from icon + label.

Accessibility

  • role="progressbar".
  • Determinate: aria-valuenow, aria-valuemin=0, aria-valuemax=100.
  • Indeterminate: aria-busy="true", no aria-valuenow.
  • aria-label from the label prop; visible label has its own id and aria-labelledby.
  • Color contrast: bar fill against track ≥ 3:1, value text ≥ 4.5:1 against its background.
  • Animation respects prefers-reduced-motion.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-progress-barOuter wrapperdata-mode=determinate|indeterminate; data-tone
ui-progress-bar-trackThe track backgroundVisual background
ui-progress-bar-fillThe filled portionWidth = value%
ui-progress-bar-labelVisible label below barVisible when showLabel=true
ui-progress-bar-valueNumeric % displayVisible when determinate AND showValue=true
ui-progress-bar-iconTone iconVisible when tone is non-default

Agent test plan

Standalone probes against /admin-test/ui-progress-bar-fixture with variants: determinate-50, determinate-100, indeterminate, regressing-value, negative-value, NaN-value, danger-tone, with-prefers-reduced-motion.

Probe list
- determinate-fill-width: value=50 → fill width corresponds to 50% (test via computed style or bbox)
- determinate-aria-valuenow: value=50 → aria-valuenow=50, valuemin=0, valuemax=100
- indeterminate-aria-busy: value=undefined → aria-busy=true, no aria-valuenow
- value-clamps-negative: value=-5 → renders as 0
- value-clamps-over-100: value=150 → renders as 100
- value-NaN-indeterminate: value=NaN → renders indeterminate
- value-never-regresses: dispatch 80 then 60, visual stays at 80
- aria-valuenow-updates-immediately: dispatch 50, next render aria-valuenow=50
- prefers-reduced-motion-no-animation: motion-reduce + indeterminate, no animation in computed style
- long-label-truncates: 80-char label, visible text clipped + aria-label has full
- tone-icon-visible-non-default: tone=danger → ui-progress-bar-icon visible
- tone-disambiguates-without-color: grayscale filter, danger still discernible from icon + label
- color-contrast-fill-track: ≥ 3:1
- color-contrast-value-text: ≥ 4.5:1
- axe-clean-serious: no serious violations