CircularProgressBar

Updated: Sep 24, 2026
Use CircularProgressBar to draw a determinate value when the dial itself is the main content. Its angular range, stroke weight, and size are configurable, which fits a charge level, countdown, camera or media meter, or any design that needs a gapped arc rather than a closed ring. By default, it sweeps clockwise from the lower left to the lower right, leaving a gap at the bottom.
It is display-only: no focus, no input, and no internal state. Unlike ProgressRing, which is a closed circle at one of two fixed sizes, this component fills the square you give it and lets you reshape the arc. Choose one indicator for a given value so readers see one progress representation. Use ProgressIndicator when a horizontal bar is clearer, and IndeterminateLoader when there is no measurable value.

Usage

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

type Props = {
  chargeLevel: number;
};

export function ChargeDial({ chargeLevel }: Props) {
  return (
    <div className="charge-dial">
      <CircularProgressBar
        progress={chargeLevel}
        animated
        aria-label={`Battery ${Math.round(chargeLevel * 100)} percent`}
      />
      <span className="uit-text-heading2">{Math.round(chargeLevel * 100)}%</span>
    </div>
  );
}
.charge-dial {
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 1;
}

.charge-dial > * {
  grid-area: 1 / 1;
}
With size omitted, the component fills its parent, so a square, layout-driven container is all it needs to scale. The component draws only the arc; a centered readout is a sibling in your own stacking container.
To reshape the arc, set the two angles and the stroke weight together. A half circle across the top, for example, starts at the left of the circle and ends at the right:
import { CircularProgressBar } from '@wearables-ui-toolkit/mrbd';

export function TopHalfMeter({ level }: { level: number }) {
  return (
    <CircularProgressBar
      progress={level}
      startAngleDegrees={180}
      endAngleDegrees={360}
      strokeWidthPx={4}
      size={80}
      aria-label={`Signal ${Math.round(level * 100)} percent`}
    />
  );
}

Props

The full prop type is CircularProgressBarProps, which extends HTMLAttributes<HTMLDivElement>.
PropTypeDefaultDescription
progress
number
0
Fraction complete from 0 to 1. Values outside the range are clamped.
animated
boolean
false
Tween arc changes with the progress spring instead of snapping. Applies to every change while set.
strokeWidthPx
number
6
Stroke weight of both the track and the arc, in the drawing coordinate system.
startAngleDegrees
number
140
Angle where the arc begins. 0 is the right of the circle, 90 the bottom, 180 the left, 270 the top.
endAngleDegrees
number
400
Angle where the arc ends. The difference from the start angle is the sweep.
size
number
—
Width and height of the component, in pixels; it is always square. Omit it to fill the parent.
aria-label
string
—
Replaces the default percentage accessible name.
className
string
''
Added to the root element.
style
CSSProperties
{}
Merged onto the root element, after the width and height the component sets, so it can override them.
The ref is forwarded to the root element, which is also the progressbar element. Every other DOM prop — aria-*, data-*, and the rest — lands there too. The component writes role, aria-valuenow, aria-valuemin, and aria-valuemax last, so values you pass for those four are overwritten; aria-label is the one it defers to.

Behavior

  • Value range.progress is a normalized fraction, not a percentage: divide your own values before passing them. Anything below 0 or above 1 is clamped.
  • Angles. Angles increase clockwise, with 0 at the right of the circle. The arc is drawn from startAngleDegrees to endAngleDegrees, so the sweep is the difference between them; the defaults sweep 260 degrees and leave a gap centered on the bottom of the circle. Keep the sweep below a full turn — for a closed circle, use ProgressRing instead — and keep the end angle greater than the start angle, carrying it past 360 when the arc has to cross the right of the circle, as the defaults do. The path is always drawn clockwise from start to end, while the fill length is computed from the difference between the two angles, so a reversed pair draws one arc and measures another and the fill stops tracking the value.
  • Sizing. Without size, the root is sized to 100% in both axes, so give it a square container and it scales with the available space. With size, the component is locked to that square. Because your style is merged after those dimensions, an explicit width or height there wins.
  • Drawing coordinates. The arc is drawn in a square coordinate system whose extent equals size when you supply it, and a fixed default extent when you do not. strokeWidthPx is expressed in that coordinate system, so a stated stroke weight is exact when size is set and scales proportionally with the parent when it is not. The drawing is centered and keeps its aspect ratio in either case.
  • Stable structure. Both the track and the arc stay mounted at progress={0}; the arc is simply drawn with zero length.
  • Materials and color. Both the track and the arc use round stroke caps, and the track is the same color as the arc at a reduced opacity, so the unfilled remainder stays readable without competing with the value. The arc color comes from --uit-color-icon-primary, which keeps the dial consistent with iconography beside it.
  • Motion.animated applies a sampled spring transition — the same SpringConfigs.PROGRESS_VALUE curve used across determinate progress. It is a persistent declarative prop backed by a CSS transition, so it applies to every progress change while set. To animate only some updates, toggle the prop around the update rather than passing a per-update flag.
  • Rendering cost. Animation runs in CSS, so rapid values do not require a React 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

  • The root carries role="progressbar" with 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 default accessible name is the rounded percentage. Passing aria-label replaces that name entirely — unlike the other determinate indicators, it is not appended — so include the value in your label if you still want it read out.
  • There is no live region and no announcement option. When the dial’s progress must be narrated as it changes, use ProgressIndicator or ProgressRing, which both offer announceUpdatesForAccessibility, or drive your own status text.
  • The dial is not focusable and takes no part in directional navigation, so nearby content must identify what it measures.
  • aria-labelledby and aria-describedby are forwarded to the same root element, so they can point at on-screen text you already render.
  • Give it a square, layout-driven container and let it scale with the available space.
  • Keep a custom arc range consistent across a screen so several dials read as one family.
  • Include the measured value in a custom aria-label because that label replaces the default percentage.
  • Set size when the stroke weight needs to land on an exact pixel value.

Avoid

  • Use the angle and stroke props instead of rebuilding the arc with custom SVG or CSS; the component keeps geometry and motion coordinated.
  • Show either CircularProgressBar or ProgressRing for one value so readers see a single progress representation.
  • Use a component with announceUpdatesForAccessibility when updates need to be announced; CircularProgressBar has no live region.
  • Provide a square container when the result must be circular. In a non-square container, the centered drawing preserves its aspect ratio and leaves empty space on the long axis.