Skip to content

RadialProgress

Two elements and no drawing JavaScript. The stylesheet paints the track, the arc and its round caps on one decorative child — a conic-gradient cut to a ring by a mask — and the value crosses into CSS once, as a registered custom property whose transition sweeps the arc between any two numbers.

  • Stable
  • React 19
  • Pure CSS ring
  • No drawing JS
80%
66%
Code
<RadialProgress value={66} showLabel aria-label="Upload progress" />
<div role="progressbar" class="wayra-radial-progress" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="Upload progress" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div><span class="wayra-radial-progress-label">66%</span></div>

value is a number from 0 to 100 and it is required. Everything else is a default you can leave alone. The center is empty until you ask for it: showLabel prints the rounded percentage, and children replaces that with your own text. At value 0 the round caps hide, so an empty ring is bare track — the component sets data-empty="true" for it.

This is a separate component from Progress, not a shape prop on it: the bar is a native <progress> element and the ring is a <div role="progressbar">, and the two share no CSS at all. They do share one design rule — the fill is the role at full strength and the track is base-300 — so a page that uses both reads as one system.

Reach for the ring when the number is the point and it sits in a tile, a card header or a dashboard cell. Reach for the bar when the progress is attached to a row of content and has a natural width to fill.

Code
<RadialProgress value={66} color="primary" size="sm" aria-label="primary" />
<RadialProgress value={66} color="secondary" size="sm" aria-label="secondary" />
<RadialProgress value={66} color="accent" size="sm" aria-label="accent" />
<RadialProgress value={66} color="neutral" size="sm" aria-label="neutral" />
<RadialProgress value={66} color="info" size="sm" aria-label="info" />
<RadialProgress value={66} color="success" size="sm" aria-label="success" />
<RadialProgress value={66} color="warning" size="sm" aria-label="warning" />
<RadialProgress value={66} color="error" size="sm" aria-label="error" />
<div role="progressbar" class="wayra-radial-progress" data-color="primary" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="primary" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="secondary" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="secondary" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="accent" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="accent" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="neutral" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="neutral" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="info" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="info" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="success" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="success" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="warning" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="warning" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-color="error" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="error" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>

The arc draws --wayra-tone at full strength, exactly as the input border does, which is why every role in a theme has to clear 4.5:1 against base-100. The base-300 track sits 1.27:1 from base-100 in the light half and 1.46:1 in the dark one, so that budget also carries the 3:1 WCAG 1.4.11 asks of a graphic that carries information. The center label, when you turn it on, is drawn from the same role solid for the same reason: the mask cuts the middle out of the ring, so the text lands on base-100 rather than on the arc.

Code
<RadialProgress value={66} size="xs" aria-label="xs" />
<RadialProgress value={66} size="sm" aria-label="sm" />
<RadialProgress value={66} size="md" aria-label="md" />
<RadialProgress value={66} size="lg" aria-label="lg" />
<RadialProgress value={66} size="xl" aria-label="xl" />
<div role="progressbar" class="wayra-radial-progress" data-size="xs" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="xs" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-size="sm" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="sm" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-size="md" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="md" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-size="lg" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="lg" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>
<div role="progressbar" class="wayra-radial-progress" data-size="xl" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="xl" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>

Diameter walks --wayra-size-selector, the unit the small controls share with Badge and Checkbox, on a ladder of its own — a ring is a display element, not a control, so it does not fit the same multipliers. Thickness derives from the diameter and is not a second free number: it is diameter / 16 + 1px, affine rather than proportional, so every step lands on a whole pixel and the small rings come out proportionally thicker.

Size Diameter Ring thickness
xs 32px 3px
sm 48px 4px
md 64px 5px
lg 80px 6px
xl 96px 7px

Measured with the default --wayra-size-selector of 0.25rem. A theme that changes that token moves the whole ladder, and a reader who scales their text moves it too, because the unit is a rem.

66%
2
Code
<RadialProgress value={66} showLabel aria-label="Upload progress" />
<RadialProgress value={66} size="lg" aria-label="Two steps left">
<strong>2</strong>
</RadialProgress>
<RadialProgress value={100} color="success" aria-label="Complete" />
<div role="progressbar" class="wayra-radial-progress" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="Upload progress" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div><span class="wayra-radial-progress-label">66%</span></div>
<div role="progressbar" class="wayra-radial-progress" data-size="lg" aria-valuenow="66" aria-valuemin="0" aria-valuemax="100" aria-label="Two steps left" style="--wayra-radial-progress-value: 66"><div class="wayra-radial-progress-ring" aria-hidden="true"></div><span class="wayra-radial-progress-label"><strong>2</strong></span></div>
<div role="progressbar" class="wayra-radial-progress" data-color="success" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" aria-label="Complete" style="--wayra-radial-progress-value: 100"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>

The center is empty by default, and there are two ways to fill it.

showLabel is a bare flag — write it and the ring prints Math.round(value) followed by a percent sign, in a <span class="wayra-radial-progress-label">. It is the common case spelled in one word, and it is deliberately not the default: an empty ring is complete, not broken, and a ring in a dense row usually wants the arc alone.

children replaces it outright. The built-in label rounds and appends %, which is one number format out of many — a decimal separator, a unit, a 2 counting steps left, or a localized percentage are all yours to pass, and passing anything wins over showLabel. Assistive tech reads the value off aria-valuenow in every one of these cases, so the center text is decoration in the strict sense.

The label is set in tabular-nums, so it does not shift when the number goes from 9% to 10%, and it takes the role color rather than the role’s -content: the mask cuts the center out of the ring, so the text sits on the page surface, not on the arc.

--wayra-radial-progress-size and --wayra-radial-progress-thickness are documented API. They are the escape hatch for the one-off ring that the five steps do not cover, and they are why there is no size="4rem" thickness="4px" pair of props — those were two CSS declarations wearing a costume.

70%
Code
<RadialProgress
value={70}
showLabel
aria-label="Hairline ring"
style={{ "--wayra-radial-progress-size": "7rem", "--wayra-radial-progress-thickness": "2px" }}
/>
<div role="progressbar" class="wayra-radial-progress" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100" aria-label="Hairline ring" style="--wayra-radial-progress-value: 70; --wayra-radial-progress-size: 7rem; --wayra-radial-progress-thickness: 2px"><div class="wayra-radial-progress-ring" aria-hidden="true"></div><span class="wayra-radial-progress-label">70%</span></div>
<div role="progressbar" class="wayra-radial-progress" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100" aria-label="Heavy ring" style="--wayra-radial-progress-value: 70; --wayra-radial-progress-size: 5rem; --wayra-radial-progress-thickness: 1.25rem"><div class="wayra-radial-progress-ring" aria-hidden="true"></div></div>

Both are plain custom properties, so they can be set inline for one instance, on a wrapper for a group, or on :root for the whole app. --wayra-radial-progress-color and --wayra-radial-progress-track work the same way if a single ring has to leave the palette.

value is required. A ring with no value is a spinner that happens to be round, and that is the Loading component, not this one — duplicating it here as value === undefined would ship the same widget twice under two APIs. Use Loading when you do not know how much is left.

Covered above: the scale is five steps and the escape hatch is a custom property. A prop that takes any CSS length is a styling API pretending to be a component API.

A half-circle gauge — starting at -90°, sweeping 180° — is a real dashboard request and it is not in v1. It would arrive as a data-shape value without changing a single prop.

RadialProgressProps extends ComponentProps<"div"> minus color.

Prop Type Default Description
value number — Required. 0-100, clamped before it reaches the DOM; ±Infinity clamps to the nearest end and NaN reads as 0.
color WayraColor "primary" The arc. All eight roles.
size "xs" | "sm" | "md" | "lg" | "xl" "md" Diameter, off --wayra-size-selector. Thickness follows.
showLabel boolean false Print the rounded percentage in the center.
children ReactNode — Center label of your own. Wins over showLabel.
unstyled boolean false Drops the classes of the host, the ring and the label; keeps the attributes.

className, style, id, ref and every other div attribute pass straight through.

<div
role="progressbar"
class="wayra-radial-progress"
data-color="primary"
data-size="md"
aria-valuenow="66"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress"
style="--wayra-radial-progress-value: 66"
>
<div class="wayra-radial-progress-ring" aria-hidden="true"></div>
<span class="wayra-radial-progress-label">66%</span>
</div>

That markup is the whole component. The stylesheet is written against the classes and the data-* attributes, so the ring works in a template language, in plain HTML, or anywhere React is not. The label span is what showLabel renders; drop it for a bare ring, or put your own text in its place — the class only sets the size, the weight and the color, and nothing else in the drawing depends on it.

The drawing is the inner element, and it has to be its own element: its background is the arc over the track as one conic-gradient, and the mask that cuts that disc down to a band would clip the center label along with it if it sat on the host. Its two pseudo-elements are the round caps — the same dot twice, one parked at twelve o’clock and one rotated to the value around the ring’s center. At value 0 the host carries data-empty="true" and both caps hide, so an empty ring is bare track. There is no geometry to copy: diameter, thickness, colors and angle all derive from the same custom properties that size the host, which is why the override section above reaches the ring too, and why nothing here ever asks you to compute a circumference. The whole state of the drawing is the one number in style.

  • The name is yours. progressbar takes no accessible name from its contents, so a ring needs aria-label or aria-labelledby even when it shows 66% in the middle. Name the task (“Upload progress”), never the number.
  • The value is announced from aria-valuenow, on the aria-valuemin/aria-valuemax scale the component pins to 0 and 100. The clamp runs before the render, so the arc and the announced number are always the same figure.
  • Not focusable, no keyboard. A progress ring reports; it does not accept input. There is no tabindex and no focus ring.
  • Reduced motion drops the arc’s transition. The ring still lands on the new value, it just gets there in one frame.
  • Windows high contrast keeps a working ring. The arc and the track are re-pointed at two system colors the forced palette keeps distinct — Highlight for the arc, Canvas for the track — and an outline draws the ring’s extent, the same rule Progress applies to its own track. The ring opts out of forced colors, which would otherwise erase its gradient, so it paints exactly that pair.
  • Contrast: the arc is the role at full strength, and every role is fitted to clear 4.5:1 against base-100. The base-300 track is 1.27:1 (light) and 1.46:1 (dark) from base-100, so the arc keeps the 3:1 WCAG 1.4.11 asks of a meaningful graphic. The center label is the same role solid, so it clears 4.5:1 as text. wayra is the palette-fidelity exception theming records.

Do

72%

Name the ring for what it measures and let the label carry the number in your own format.

Don't

72%

An aria-label that repeats the number. It is already in aria-valuenow, and it goes stale the moment the value moves.

Do

Syncing 4 of 10 files

A ring next to the text it belongs to. The percentage is arithmetic you do; the ring shows the shape of it.

Don't

Loading…

A full ring standing in for an unknown wait. That is a spinner — use Loading, which is built for it.