Progress indicators
components specs/components/progress-indicators.kmd
Visual feedback that an operation is in progress — linear (bar) or circular (spinner), determinate (known %) or indeterminate (unknown duration). Material parity (`/components/progress-indicators`).
When this spec applies
Primary triggers
- Add a progress indicator
All triggers
- Show a known-progress operation
- Show that work is happening without a known duration
- Replace generic spinner with branded indicator
Specification body
Spec — Progress indicators
Facet Visual of Koder Design. Material parity: https://m3.material.io/components/progress-indicators.
2 shapes × 2 modes
| Shape \ Mode | Determinate | Indeterminate |
|---|---|---|
| Linear | Bar fills 0 → 100% | Sliding band animates left → right (no fill) |
| Circular | Arc sweeps 0 → 360° | Arc rotates continuously |
Pick:
- Shape: linear when there's horizontal real estate (above content, banner-like); circular when space is constrained (button, dialog, icon-sized).
- Mode: determinate when % is known (file upload); indeterminate when not (network handshake, ML inference).
Anatomy (linear, determinate)
45% complete
████████░░░░░░░░░░░░░░░░░░░░░
←──────── 100% ────────────→
- Track height: 4 px (default) / 8 px (large)
- Track bg:
surface-container-highest(orprimary12%) - Active fill:
primarycolor - Corner radius: track height / 2 (pill shape)
- Stop point (Material 3): 4 px circle of
primarycolor at the end of the track to indicate "track end"
Anatomy (circular):
╭ ━━━╮
┃ ╲
┃ ● ← stroke head
╲ ╱
╲ ━╱
- Diameter: 24 px (small) / 32 px (default) / 48 px (large)
- Stroke width: 4 px (default), scales with size
- Track:
surface-container-highestring - Active arc:
primarycolor
R1 — Linear determinate
- Fill animates from current % to new % (motion-fast, ~150 ms)
- Don't reset to 0 between updates — smooth interpolation
- At 100%: brief settle (stop-point disappears as track ends)
- Backwards progress (resume after pause) allowed — animate back
R2 — Linear indeterminate
- Sliding band: 30-50% of track width, slides left → right continuously
- Cycle duration: 2 s
- Easing: cubic ease-in-out
- No stop point in indeterminate mode
R3 — Circular determinate
- Arc length proportional to %
- Starts at 12 o'clock; sweeps clockwise
- Stroke ends with rounded cap
R4 — Circular indeterminate
- Full circle rotates clockwise: 1.5 s per rotation
- Arc length pulses: 25% → 75% → 25% over 1 cycle
- Two pulses per rotation (Material 3 default; document if changed)
R5 — Buffer indicator (linear only)
For media / download with "buffered ahead of playhead":
████████████░░░░░░░░░░░░░░░░░░░
━━━━━━━━━━━━━━━━━░░░░░░░░░░░░░░
↑ ↑
playhead (filled)
buffered (lighter)
- Buffer color:
primary40% (lighter than active) - Buffer always ≥ playhead position
R6 — Indicator placement
| Placement | Use |
|---|---|
| Above content (full-width linear) | Page-level loading |
| In-button (small circular replaces text) | Submitting a form |
| Inline next to label (24 px circular) | Item-level operation (e.g., row syncing) |
| Centered in container (large circular) | Empty card / pane loading |
| In app bar bottom edge (linear) | Long page operations (refresh) |
R7 — Combine with action button
When a button triggers a long operation, replace its content with a small circular indicator (24 px):
Before: [ Submit ]
During: [ ◐ ] ← circular indeterminate, 24 px
After: [✓ Saved ]
- Button width stays the same (avoid layout shift)
- Disable the button during operation
- On success: brief check icon (1 s) before reverting to idle / new label
R8 — Multi-step / segmented (linear)
Linear can show discrete steps:
███ ███ ███ ░░░ ░░░ Step 3 of 5
step1 step2 step3 step4 step5
- 2 px gap between segments
- Each segment radius = track height / 2
- Filled segments use active color; unfilled use track color
Used for: onboarding stepper, multi-page form indicator.
R9 — Empty / not-started state
Don't show the indicator at 0% — looks broken. Either:
- Show indicator only after operation starts (delay 200-400 ms)
- Begin animation as soon as visible (indeterminate)
For determinate, if value is genuinely 0, render the track only (no fill) and show "0%" or "Not started" label nearby.
R10 — Completion state
Determinate at 100%:
- Brief settle (200 ms)
- Fade out indicator OR replace with success icon (✓)
- For background operations, fade out after 1-2 s
R11 — Accessibility
- Determinate linear / circular:
role="progressbar"+aria-valuenow/aria-valuemin/aria-valuemax+aria-labeldescribing what's loading - Indeterminate:
role="progressbar"+aria-busy="true"+ noaria-valuenow(omitted means indeterminate per ARIA) - For long operations, periodically announce progress to screen reader
(e.g., every 25%):
aria-live="polite"on container - Don't rely on color alone — pair with text label ("45% complete") for determinate
R12 — Animation
- Determinate update: motion-fast (~150 ms) interpolation
- Indeterminate linear cycle: 2 s
- Indeterminate circular cycle: 1.5 s
- All animations respect
prefers-reduced-motion:- Linear indeterminate: pulsing opacity 50% → 100% instead of sliding
- Circular indeterminate: no rotation; opacity pulse instead
R13 — States
Progress indicators don't have hover / pressed / focused states (they're decorative output, not interactive).
Disabled state IS valid — operation cancelled:
- Indicator fades to 38% opacity
- Active animation stops
- Track stays visible briefly before fading entirely
R14 — Density
| Density | Linear height | Circular default |
|---|---|---|
| Compact | 2 px | 20 px |
| Default | 4 px | 32 px |
| Comfortable | 8 px | 48 px |
R15 — Per-preset variation
| Preset | Linear | Circular |
|---|---|---|
material3 | Track + stop point at end | Stroke with rounded cap, 2 pulses/rotation |
material2 | No stop point; flat ends | Full rotation, 1 pulse |
ios_cupertino | Solid bar without stop | UIActivityIndicator gear-style |
gnome | GtkProgressBar style | Adwaita spinner (3 dots) |
windows_11 | Accent bar with subtle glow | Fluent spinner (dots circling) |
brutalist | Solid block, no easing (step changes only) | Square instead of circle, hard rotation |
terminal_classic | [██████░░░░] 60% ASCII | Spinner cycle ` |
R16 — Forbidden patterns
- ❌ Indeterminate when % is actually known (defeats user trust)
- ❌ Multiple progress indicators stacked on the same operation
- ❌ Indicator < 24 px for circular (unreadable)
- ❌ Animation that resets to 0 between % updates
- ❌ Showing indicator for < 200 ms (annoying flash; either delay it or do without)
- ❌ Operation that runs > 30 s with no progress feedback (always show indicator)
- ❌ Indicator without label / context (user can't tell WHAT is loading)
- ❌ Hiding the button label entirely on submit without a replacement (no feedback that work started)
Cross-link
themes/motion.kmd— easing for indeterminate cyclethemes/color-roles.kmd—primaryfor active,surface-container-highestfor trackinteraction/states.kmd— disabled overlaycomponents/buttons.kmd— in-button progress patternfoundations/elements.kmd— Output family
Requirements (testable)
Requirement: Determinate progress interpolates smoothly {#req-progress-determinate-interpolation}
A determinate indicator SHALL animate its fill from the current value to each new value using a motion-fast (~150 ms) interpolation, and SHALL NOT reset to 0 between updates; backwards progress (resume after pause) SHALL animate back to the lower value rather than snapping.
Scenario: Forward update interpolates from current value
- GIVEN a linear determinate indicator currently rendering 45%
- WHEN its value is updated to 60%
- THEN the fill animates from 45% to 60% over roughly 150 ms
- AND the fill never returns to 0% during the transition
Scenario: Resume after pause animates backwards
- GIVEN a determinate indicator rendering 70%
- WHEN its value is updated to a lower value of 55% (operation resumed at an earlier point)
- THEN the fill animates back down to 55%
- AND the change is a smooth interpolation, not an instant snap
Requirement: Progress indicators expose ARIA progressbar semantics {#req-progress-a11y-determinate}
A determinate linear or circular indicator SHALL expose role="progressbar" with aria-valuenow, aria-valuemin, and aria-valuemax set, plus an aria-label describing what is loading, and SHALL pair the value with a visible text label (e.g. "45% complete") so meaning is not conveyed by color alone; an indeterminate indicator SHALL expose role="progressbar" with aria-busy="true", SHALL NOT set aria-valuenow, and SHALL carry aria-live="polite" on its container so long-running progress can be announced.
Scenario: Determinate indicator carries value and label
- GIVEN a linear determinate indicator loading a file upload at 45%
- WHEN the indicator is rendered
- THEN it has
role="progressbar"witharia-valuenow="45",aria-valuemin="0", andaria-valuemax="100" - AND it has a non-empty
aria-labeldescribing the operation - AND a visible text label reading "45% complete" is present
Scenario: Indeterminate indicator omits aria-valuenow
- GIVEN an indeterminate indicator for a network handshake of unknown duration
- WHEN the indicator is rendered
- THEN it has
role="progressbar"andaria-busy="true" - AND it has no
aria-valuenowattribute - AND its container carries
aria-live="polite"
Requirement: In-button progress preserves layout and disables the button {#req-progress-in-button}
When a button triggers a long operation and its content is replaced by a small (24 px) circular indeterminate indicator, the button width SHALL stay unchanged to avoid layout shift, the button SHALL be disabled for the duration, and on success a brief check icon SHALL be shown before reverting to an idle or new label; the label SHALL NOT be hidden without a replacement indicator.
Scenario: Submitting swaps label for indicator without resizing
- GIVEN a "Submit" button at a fixed rendered width
- WHEN the user triggers the long operation
- THEN the button content is replaced by a 24 px circular indeterminate indicator
- AND the button width is identical to its pre-submit width
- AND the button is disabled
Scenario: Success shows check before reverting
- GIVEN a button showing the in-button indicator during an operation
- WHEN the operation completes successfully
- THEN a check icon is shown briefly (about 1 s)
- AND the button then reverts to an idle or new label and becomes enabled again
Requirement: Indeterminate animation honors reduced motion {#req-progress-reduced-motion}
Indeterminate indicators SHALL animate continuously by default (linear sliding band on a 2 s cycle, circular rotation on a 1.5 s cycle), but when prefers-reduced-motion is set they SHALL replace translational/rotational motion with an opacity pulse (50% → 100%) and SHALL NOT slide or rotate.
Scenario: Default indeterminate linear slides
- GIVEN a linear indeterminate indicator and no reduced-motion preference
- WHEN it animates
- THEN a sliding band of 30–50% track width moves left → right on a 2 s cycle
- AND no stop point is rendered
Scenario: Reduced motion replaces rotation with opacity pulse
- GIVEN a circular indeterminate indicator
- WHEN
prefers-reduced-motionis set - THEN the indicator does not rotate
- AND it animates an opacity pulse between 50% and 100% instead
Requirement: Forbidden progress patterns are not emitted {#req-progress-forbidden-patterns}
An implementation SHALL NOT use an indeterminate indicator when the percentage is actually known, SHALL NOT render a circular indicator smaller than 24 px, SHALL NOT stack multiple indicators on the same operation, and SHALL NOT flash an indicator for under 200 ms (either delay it or omit it).
Scenario: Known progress uses determinate mode
- GIVEN an operation whose completion percentage is known (a file upload)
- WHEN an indicator is chosen for it
- THEN a determinate indicator is used
- AND the indicator is not indeterminate
Scenario: Sub-threshold operation does not flash an indicator
- GIVEN an operation expected to finish in under 200 ms
- WHEN the indicator would be shown
- THEN its appearance is delayed (200–400 ms) so a completing operation shows nothing
- AND no indicator flashes for under 200 ms
References
specs/foundations/elements.kmdspecs/themes/color-roles.kmdspecs/themes/motion.kmdspecs/interaction/states.kmd