Components · Feedback indicators

Progress bar

Show how far through a task the user is. Two sizes, a determinate fill or an indeterminate stripe, and exactly the chrome the job needs — no more.

Documentedby Derek Fidler
size — sm
73 %
size — md
73 %
indeterminate

Overview

Progress bar lives in @flatpay-dk/ui. Pass a numeric value for a determinate fill, or value={null} to render the indeterminate stripe. The fill is translucent black so the bar adopts whatever surface it sits on — no theming required.

Use a label, every time

Pair the bar with copy that names what's progressing — a filename, a step count, a job name. The bar is reinforcement; the label is the content. Pass an aria-label (or aria-labelledby) so screen readers carry the same information.

Sizes

Two heights. sm (4 px) sits inline next to content; md (16 px) carries a single-task surface where progress is the focus.

size — sm

4 px tall. For inline progress in dense rows — a cell in a table, a row in a list. Quiet enough to sit beside content.

size — md

16 px tall. The headline indicator on a single-task surface — file upload, onboarding step, batch import.

Indeterminate

Pass value={null} when the duration is unknown — third-party syncs, ingestion jobs, webhook acks. A 35 %-wide stripe sweeps left to right on a 1.4 s loop. Reaches the right edge, then loops; never bounces.

Honour prefers-reduced-motion: users who've opted out see the stripe held still in the centre as a visible “still working” mark.

In context

Where progress bars earn their place — a single task with a measurable end. If you can't name what 100 % means, you don't want a bar.

File upload

statements_apr_2026.csv2.4 / 4.1 MB

Onboarding

Step 3 of 5 — Connect bank60 %

Indeterminate

Syncing from GitHubWorking…

Use indeterminate when total duration is unknown — webhook acks, ingestion jobs, third-party syncs. Once you can estimate, switch to a value.

Anatomy

The bar is two layers: a track and a value fill.

  1. Track

    The full-width container. 5 % black so it picks up whatever surface it sits on. Rounded — 2 px on `sm`, 8 px on `md`.

  2. Value

    The filled portion. 28 % black, same surface adoption. Width is driven by `value / max`; transitions via transform-scale on the X axis so the bar never animates `width`.

Behavior

Watch the value advance and the bar follow. Width changes use a 300 ms cubic-bezier(0.16, 1, 0.3, 1) ease — the same curve as the rest of the system's entrances. Indeterminate mode runs at 1.4 s per loop.

Ready0 / 100

Accessibility

The bar always reports role="progressbar". Determinate bars expose aria-valuenow / min / max; indeterminate bars omit aria-valuenowso assistive tech announces “busy” instead of a misleading number.

Animation respects user preference

The indeterminate stripe halts under prefers-reduced-motion: reduce; the determinate transition collapses to instant. The bar still communicates state — just statically.

Code

Determinate

tsx

import { ProgressBar } from "@flatpay-dk/ui";

<ProgressBar
  size="md"
  value={73}
  aria-label="Upload progress, 73 percent"
/>;

Indeterminate

tsx

<ProgressBar
  size="md"
  value={null}
  aria-label="Syncing from GitHub"
/>;

Custom max

tsx

// Total bytes uploaded out of total bytes — no need to convert to percent.
<ProgressBar
  size="sm"
  value={2_437_120}
  max={4_298_753}
  aria-label="Upload progress"
/>;

Best practices

A progress bar earns its place when there is exactly one task with a measurable end. Anything else is decoration.

Step 3 of 5 — Connect bank

Do

Pair the bar with the thing it's measuring — filename, step count, job name. The bar reinforces; the label explains.

Don't

Don't ship a stand-alone bar with no label. The user reads the colour, not what's progressing.

Do

Use indeterminate when the end is unknown. Switch to a value as soon as one is available.

Don't

Don't fake progress. A bar that crawls to 95 % and stalls erodes trust faster than no bar at all.

Props

PropTypeDefaultDescription
valuenumber | nullnullNumeric progress in [0, max]. Pass null for indeterminate.
maxnumber100Upper bound of the value range.
size'sm' | 'md''md'4 px or 16 px tall.
onFillbooleanfalseSet when placing the bar on a coloured surface. Reserved for a future token swap; today the visual matches the default.
aria-labelstringAccessible name. Required unless a labelledby relationship is wired externally.
classNamestringForwarded to the wrapper.