ProgressRing

Updated: Sep 24, 2026
ProgressRing draws a determinate value as an arc around a full circular track. Reach for it when a circle reads better than a bar — a compact upload badge, a countdown next to an icon, a status glyph in a tight corner of a screen. The arc starts at the top of the circle and sweeps clockwise.
It is display-only: it takes no focus, handles no input, and holds no state. It also has a fixed square footprint chosen by the size variant, so it never reflows the layout around it as the value changes. Use CircularProgressBar instead when you need a partial arc, a caller-controlled stroke weight, or a dial that grows with its parent; use ProgressIndicator when a horizontal bar reads more clearly; use IndeterminateLoader when there is no value to show.

Usage

import { ProgressRing, ProgressRingSize } from '@wearables-ui-toolkit/mrbd';

type Props = {
  uploaded: number;
  total: number;
};

export function UploadStatus({ uploaded, total }: Props) {
  return (
    <div className="upload-status">
      <ProgressRing
        progress={total === 0 ? 0 : uploaded / total}
        size={ProgressRingSize.LARGE}
        animated
        announceUpdatesForAccessibility
        aria-label="Uploading photos"
      />
      <span className="uit-text-body2">Uploading photos</span>
    </div>
  );
}
.upload-status {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--uit-spacing-small);
}
The ring renders only the track and the arc; it has no content slot. To put an icon or a value inside the circle, stack the ring and your content in a container of your own:
import { IconImage, ProgressRing } from '@wearables-ui-toolkit/mrbd';
import type { IconSource } from '@wearables-ui-toolkit/mrbd';

type Props = {
  progress: number;
  icon: IconSource;
};

export function RingWithGlyph({ progress, icon }: Props) {
  return (
    <div className="ring-slot">
      <ProgressRing progress={progress} aria-label="Sync" />
      <IconImage source={icon} />
    </div>
  );
}
.ring-slot {
  display: grid;
  place-items: center;
}

.ring-slot > * {
  grid-area: 1 / 1;
}

Props

The full prop type is ProgressRingProps, which extends HTMLAttributes<HTMLDivElement>.
PropTypeDefaultDescription
progress
number
0
Fraction complete from 0 to 1. Values outside the range are clamped.
size
ProgressRingSize
ProgressRingSize.SMALL
Fixed square footprint of the ring.
animated
boolean
false
Tween arc changes with the progress spring instead of snapping. Applies to every change while set.
announceUpdatesForAccessibility
boolean
false
Publish the rounded percentage into a polite live region so screen readers narrate each change.
aria-label
string
—
Appended after the percentage in the accessible name. Blank or whitespace-only labels are ignored.
className
string
''
Added to the outer wrapper.
style
CSSProperties
{}
Applied to the outer wrapper.
The ref is forwarded to the progressbar element, not to the outer wrapper. Other DOM props you pass — aria-*, data-*, and the rest — are applied to that same progressbar element, while className and style stay on the outer wrapper. The component writes role, aria-valuenow, aria-valuemin, and aria-valuemax last, so values you pass for those four are overwritten.

Types and enums

ProgressRingSize
MemberDescription
SMALL
Compact ring for inline and accessory placements.
LARGE
Prominent ring for a screen’s primary status.

Behavior

  • Value range.progress is a normalized fraction, not a percentage and not a range: divide your own values before passing them, and guard against dividing by zero. Anything below 0 or above 1 is clamped.
  • Geometry. The arc begins at the top of the circle, sweeps clockwise through a full turn, and uses round stroke caps, so a very small non-zero value still reads as a visible mark. The track is always a complete circle behind it.
  • Sizing. Both size variants render a fixed square with a fixed stroke weight, inside an inline-level wrapper that flows next to adjacent content. The component does not stretch to its parent; pick the variant that fits, or reach for CircularProgressBar when the dial must scale with the available space.
  • Stable structure. The arc element stays mounted at progress={0}, drawn with zero length, so the ring does not restructure its markup as the value crosses zero.
  • Materials and color. The track uses --uit-color-background-track and the arc uses --uit-color-control-active, which keeps both legible against the additive-light display.
  • Motion.animated applies a sampled spring transition — the same SpringConfigs.PROGRESS_VALUE curve used across determinate progress — to the arc. It is a persistent declarative prop backed by a CSS transition, so it affects every subsequent change while it is set. To animate only some updates, toggle it around the update.
  • Rendering cost. Animation is driven by CSS, not by React state, so a fast-updating value does not force a render per frame.
  • Reduced motion. The component does not read a motion preference for you. Call usePrefersReducedMotion and drop animated if your app honors a reduced-motion preference.

Accessibility

  • Renders an element with role="progressbar", aria-valuenow set to the rounded percentage, aria-valuemin of 0, and aria-valuemax of 100. Note that aria-valuenow is the percentage, not the 0-to-1 fraction you passed.
  • The accessible name is the rounded percentage on its own, or the percentage followed by your aria-label when you supply one — 42%, Uploading photos. A label that is empty or only whitespace is ignored.
  • Live-region announcements are off by default. Turning announceUpdatesForAccessibility on publishes just the percentage into a polite, atomic live region that sits outside the progressbar element so the change is reliably picked up. Your label is part of the static accessible name, so it is not repeated on every update.
  • Enable announcements for a single, primary ring whose progress is worth narrating, and leave them off for decorative or duplicated rings.
  • The ring is not focusable and takes no part in directional navigation. Whatever the ring describes must be identifiable from the surrounding content.
  • Because aria-* props land on the progressbar element, aria-labelledby and aria-describedby work as expected for pointing at on-screen text.
  • Normalize your value to the 0-to-1 range at the edge of your state, not inside the render path.
  • Keep the ring visually adjacent to the item whose progress it reports.
  • Choose one determinate component per value.
  • Center a glyph or readout by stacking siblings in your own grid, and keep that content out of the accessible name by labelling the ring directly.

Avoid

  • Avoid treating the ring as a dial or gauge the user can turn; it accepts no input.
  • Compose a stacking container for centered content. ProgressRing does not accept content for the center.
  • Avoid leaving a ring pinned at a value after the work finishes — swap it for the settled state.
  • Apply className and style to the outer wrapper as documented; they do not reach the progressbar element.
  • Avoid turning announcements on for several rings on one screen; the live regions will talk over each other.