BetaFeedback

ProgressCircle

How far along something is, drawn as a ring.

Overview

How far along something is, drawn as a ring.

First example

<ProgressCircle value={72}>
<ProgressCircle.Indicator />
<ProgressCircle.Value />
</ProgressCircle>
<ProgressCircle value={100} variant="success">
<ProgressCircle.Indicator />
<ProgressCircle.Value></ProgressCircle.Value>
</ProgressCircle>

Anatomy

<ProgressCircle value={72}>
  <ProgressCircle.Indicator />
  <ProgressCircle.Value />
</ProgressCircle>
  • ProgressCircle — the root. It clamps the value, resolves the recipe and computes the ring's geometry once, then publishes all three.
  • ProgressCircle.Indicator — the ring: the track, and the arc of it that is done.
  • ProgressCircle.Value — how far along, written in the middle.

Written with no children it is the ring alone, which is the form a row wants.

It is the ProgressBar bent into a circle — the same five variants, the same clamped range, the same formatOptions, the same 240ms — and it is a second component rather than a shape prop on that one because the two share no geometry at all: a bar is a View that grows, and this is an SVG path whose dash offset moves.

There is no isIndeterminate here either. The reason is the bar's, and it is written up there.

Usage

The number in the middle

<ProgressCircle value={72}>
  <ProgressCircle.Indicator />
  <ProgressCircle.Value />
</ProgressCircle>

<ProgressCircle value={100} variant="success">
  <ProgressCircle.Indicator />
  <ProgressCircle.Value>✓</ProgressCircle.Value>
</ProgressCircle>

Value is positioned absolutely so it centres on the ring rather than pushing it: the root is a box the size of the circle, and a text node in the flow would make that box taller than the drawing in it.

It formats through the root's formatOptions, on the same rule as the bar's — the fraction for a percentage, the value for anything else. Children replace it outright.

Not at sm. 32 points across, less two strokes, is narrower than "64 %" is wide. That step is the ring on its own.

The motion

The arc sweeps to each new length over 240ms — the bar's number, because two progress indicators on one screen moving at two speeds is a bug nobody files and everybody sees.

It is a dash offset on one path rather than a shape rebuilt per value, which is what keeps one rounded cap at each end while it moves, and it is an animated prop rather than an animated style, because strokeDashoffset is an SVG attribute.

<ProgressCircle.Indicator animation={false} />

The ring starts at twelve o'clock. The turn is on the indicator's wrapper rather than on each path: Circle's own originX / originY / rotation emit an invalid DOM property on web, and one rotation on the box is one thing to read instead of three props on two circles.

Root props

ProgressCircleProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
variantProgressCircleVariant | undefinedNo
sizeProgressCircleSize | undefinedNoThe ring's diameter, its stroke and the type inside it.
radiusnumber | undefinedNoThe ring's radius **in points**, and the one place in this library where `radius` means what it means in geometry rather than a corner — a circle has no corner to round. A raw number, so it lives outside the style cache and outside the vocabulary (R6): it wins over `size` the way a raw `color` wins over a variant's token. Reach for it when the ring has to line up with something already on the screen; reach for `size` otherwise.
strokeWidthnumber | undefinedNoThe stroke's thickness in points, overriding `size`. Clamped to half the diameter.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7). It lands on the arc.
valuenumber | undefinedNoHow far along. Clamped into `[minValue, maxValue]`.
minValuenumber | undefinedNo
maxValuenumber | undefinedNo
formatOptionsNumberFormatOptions | undefinedNoHow `ProgressCircle.Value` reads the fraction.
isDisabledboolean | undefinedNoDims the ring. There is nothing to press, so nothing else changes.
styleStyleProp<ViewStyle>No
childrenReactNodeNo

Slots

ProgressCircleIndicatorProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
animationboolean | undefinedNo`false` snaps the arc to its new length instead of sweeping to it.
styleStyleProp<ViewStyle>No

ProgressCircleValueProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

Variants, sizes and colour

Sizes

sizeDiameterStrokeValue type
sm32312/16
md48414/20
lg64516/24

Bigger than the Spinner's ladder at every step, and that is the difference between the two: a spinner says "wait" and this says how long, which usually means it has a number written in the middle of it.

The stroke is centred on the path, so the ring fits its box: the path's radius is half the diameter less half the stroke.

Variants and colour

The ProgressBar's table, unchanged — one neutral ring, and five arcs on it. color is a raw value (R7) and lands on the arc through the same bgSelected role.

The two colours reach the SVG as strings, not styles: a path is stroked by a prop rather than by a stylesheet. The recipe still owns them, on two slots it never renders — the same thing the Tabs recipe does with its content slot — because that is what makes a raw color reach the arc through resolveTint, which only maps roles a variant declared.

Accessibility

  • accessibilityRole="progressbar" on the root, overridable.
  • accessibilityValue carries the caller's range and value, not the clamped fraction.
  • The ring itself is a drawing; what a screen reader reads is that value.

Migration from legacy

This component declares no rule of its own. The migration guide covers variants, colours and slots.

Implementation notes

`radius` is a number here

<ProgressCircle radius={28} value={72} />

The one place in this library where radius means what it means in geometry, because a circle has no corner to round. It is a raw number, so it lives outside the style cache and outside the vocabulary (R6): it wins over size exactly as a raw color wins over a variant's token.

Reach for it when the ring has to line up with something already on the screen — an avatar, a row's height. Reach for size otherwise: the ladder is the vocabulary.

strokeWidth is the same kind of prop, and both are clamped. A stroke thicker than the ring is wide would draw a path with a negative radius, which renders nothing, on one platform, with no error.

`react-native-svg`

An arc with a rounded cap is a stroked path, so ProgressCircle.Indicator imports react-native-svg — an optional peer. The alternative in plain views is two rotated half-discs clipped by a third, which cannot round its own ends.

Only that one file imports it, and this component is its own subpath export (@xaui/native/progress-circle), so a project that never renders a ring never pays for it. The Select's check and the Icon's chevron are already in the same position.

Alignment with the reference implementation

Identical: the three sizes, the clamped range, formatOptions, the SVG ring with a rounded cap starting at twelve o'clock, and Indicator · Value as slots.

Three deltas:

TheirsOursWhy
size={40} — the ladder takes a numbersize is tokens, radius is the numberR6: a vocabulary prop takes tokens or it is not a vocabulary. The raw escape hatch gets its own name, like color does.
strokeWidth / trackColor / fillColor on the IndicatorstrokeWidth on the root, colours from the recipeR1 — colours are the recipe's, and variant plus color is the way in.
color="success"variant="success"Their color is our variant, as on the bar.

Extending it

useProgressCircle() is exported (R10) and carries the resolved geometry, the two stroke colours, the fraction and the value — enough to draw a second arc, a mark at the target, or an icon in place of the number. Outside a <ProgressCircle> it throws by name.