ProgressIndicator

Updated: Sep 24, 2026
ProgressIndicator shows a determinate value as a filled portion of a horizontal track. Reach for it when the amount of work completed is known and can be updated accurately, and when horizontal space is the clearest way to express it — a download in a list row, a step counter across a flow, an upload under a thumbnail.
It is display-only: it never takes focus, handles no pointer or key input, and owns none of your state. You pass a value, it clamps that value to the range, draws the bar, and publishes the matching progressbar semantics. Use IndeterminateLoader when completion cannot be estimated, ProgressRing or CircularProgressBar when a circular form fits the layout better, and SliderBar or IsolatedControl when the user is meant to change the value.

Usage

import { ProgressIndicator } from '@wearables-ui-toolkit/mrbd';

type Props = {
  bytesReceived: number;
  bytesTotal: number;
};

export function DownloadRow({ bytesReceived, bytesTotal }: Props) {
  return (
    <div className="download-row">
      <span className="uit-text-body2">Downloading update</span>
      <ProgressIndicator
        value={bytesReceived}
        maximumValue={bytesTotal}
        animated
        aria-label="Downloading update"
      />
    </div>
  );
}
.download-row {
  display: flex;
  flex-direction: column;
  gap: var(--uit-spacing-xsmall);
  padding: var(--uit-spacing-medium);
}
The bar fills the width of whatever container you put it in, so control its width with layout rather than with a fixed measurement.
For a dense row where a full-weight bar would crowd the content, use the thin variant and turn the announcements off so a list of rows does not narrate over itself:
import {
  ProgressIndicator,
  ProgressIndicatorSize,
  TextView,
  TextStyle,
} from '@wearables-ui-toolkit/mrbd';

type Episode = {
  id: string;
  title: string;
  secondsPlayed: number;
  secondsTotal: number;
};

export function EpisodeRow({ episode }: { episode: Episode }) {
  return (
    <div className="episode-row">
      <TextView textStyle={TextStyle.BODY2}>{episode.title}</TextView>
      <ProgressIndicator
        size={ProgressIndicatorSize.THIN}
        value={episode.secondsPlayed}
        maximumValue={episode.secondsTotal}
        isActive={episode.secondsPlayed > 0}
        announceUpdatesForAccessibility={false}
        aria-label={`${episode.title} playback position`}
      />
    </div>
  );
}
.episode-row {
  display: flex;
  flex-direction: column;
  gap: var(--uit-spacing-2xs);
}

Props

The full prop type is ProgressIndicatorProps, which extends HTMLAttributes<HTMLDivElement>.
PropTypeDefaultDescription
value
number
0
Current value. Clamped to the range before it is drawn and reported.
minimumValue
number
0
Low end of the range, reported as aria-valuemin.
maximumValue
number
1
High end of the range, reported as aria-valuemax.
size
ProgressIndicatorSize
ProgressIndicatorSize.DEFAULT
Bar thickness variant.
isActive
boolean
true
Draws the fill in the active color when true, the idle color when false.
animated
boolean
false
Tween value and fill-color changes instead of snapping. Applies to every change while set.
announceUpdatesForAccessibility
boolean
true
Publish the rounded percentage into a polite live region so screen readers narrate each change. Set false to keep the indicator silent.
aria-label
string
—
Appended after the percentage in the accessible name. Blank or whitespace-only labels are ignored.
className
string
''
Added to the root element.
style
CSSProperties
{}
Merged onto the root element.
The ref is forwarded to the root element. Any other aria-* attribute you pass — aria-describedby and aria-labelledby, for example — is applied to the inner progressbar element, where it can take effect; every other DOM prop, including data-*, lands on the root. The component writes role, aria-valuenow, aria-valuemin, and aria-valuemax on that inner element last, so values you pass for those four are overwritten.

Types and enums

ProgressIndicatorSize
MemberDescription
DEFAULT
Standard thickness, suitable for most layouts.
THIN
Reduced thickness for dense rows and compact overlays.

Behavior

  • Range and clamping. The value is clamped to [minimumValue, maximumValue], so a stale or out-of-range value never overdraws the track. Pass the range that matches your source of truth — bytes, seconds, steps — rather than converting to a fraction yourself.
  • Reported percentage. The percentage is rounded to a whole number. When minimumValue and maximumValue are equal the indicator reports 100%, which keeps a zero-length range from reading as “no progress”.
  • Sizing. The component stretches to the width of its parent and takes its height from the size variant. It has no intrinsic width of its own.
  • Active state.isActive changes color only. Because the bar does not expand on state change, it keeps a constant thickness in both states, so an active/inactive flip never shifts surrounding layout.
  • Motion. With animated unset, value and color changes apply immediately. Setting it applies a sampled spring transition to the fill width and a container-state transition to the fill color. It is a persistent declarative prop backed by CSS transitions, so it affects every subsequent change while it is set; to animate only some updates, toggle it around the update.
  • Reduced motion. The component does not read a motion preference for you. Call usePrefersReducedMotion and drop animated when the user has asked for less motion.
  • Materials and color. The track uses --uit-color-background-track. The fill uses --uit-color-control-active when active and --uit-color-control-idle when inactive, so the bar stays legible on the additive-light display without any color work from you.
  • Edge cases. A value equal to minimumValue renders an empty track rather than removing the bar, so a row’s height does not change when progress starts at zero.

Accessibility

  • Renders an element with role="progressbar" carrying aria-valuenow, aria-valuemin, and aria-valuemax from the clamped value and the range, so aria-valuenow is reported in your own units rather than as a percentage. The drawn bar inside it is hidden from assistive technology, so the component contributes exactly one progressbar to the accessibility tree.
  • The accessible name is the rounded percentage on its own, or the percentage followed by your aria-label when you supply one — 50%, Downloading update. Give it a label that names the work, not the widget. A label that is empty or only whitespace is ignored and the bare percentage is used.
  • Updates are announced through a polite, atomic live region by default, which is a sibling of the progressbar element so the change is reliably picked up. Turn announceUpdatesForAccessibility off when several indicators share a screen, when the value updates many times per second, or when a nearby status line already narrates the same progress.
  • The indicator is not focusable, so nothing about it is reachable by directional navigation and it never becomes a stop between your controls. Surrounding content must say what is progressing.
  • Use aria-labelledby when the naming text already exists on screen, and aria-describedby for supplementary detail; both are forwarded to the progressbar element.
  • Bind the value to real work and clamp or validate it where your state is produced.
  • Pair the bar with a short label or status line when the task is not obvious from context.
  • Use THIN inside dense rows where a full-weight bar would crowd the content.
  • Keep minimumValue and maximumValue in the units your data already uses and let the component derive the percentage.
  • Turn announcements off for repeated rows in a list and leave them on for the single indicator that owns the screen.

Avoid

  • Avoid presenting progress that stalls near completion; use IndeterminateLoader when you cannot measure.
  • Avoid showing two components for the same value.
  • Keep it non-focusable because it carries status rather than an action.
  • Avoid leaving animated on for a value that already arrives smoothly — the transition will lag behind the data.
  • Use the size variant and isActive for supported appearance changes. Internal track and fill elements are not supported customization surfaces.