Skip to main content
WireKit
Copy for LLM

Progress Radial

A circular progress ring for dashboards and stat tiles, where progress is the same idea as a bar.

The ring is one conic-gradient masked into an annulus and driven by a single custom property — no canvas, no SVG arc math. A value change is one style write.

Radial progress in four sizes and intents
25%
62%
88%
Full

Sizes

Four sizes, from an inline sm ring beside a label to an xl hero ring on a dashboard. Only the size changes below — same value, same intent.

Four sizes
66%
66%
66%
66%

Intents

The color axis is the one used across the library — primary by default, plus the state colors. Same value and size below, so only the color changes.

Every intent
70%
70%
70%
70%
70%
70%
70%

Animation

Pass animate to sweep the fill from empty to its value on first paint — and to animate later value changes instead of snapping. Use the preview's replay control to watch it again.

Sweep the fill on load
72%
90%

It is pure polish: the sweep is gated by prefers-reduced-motion, and a browser that doesn't support the on-load animation simply shows the ring at its value. The ring is complete either way.

The value and what it is out of

value and max work like the linear bar. The sweep comes from the ratio, so a max that is not 100 still draws correctly:

{{-- Half a ring — not 30% --}}
<x-wirekit::radial-progress :value="30" :max="60" label="Tasks done" />

A value above max is clamped rather than drawn: a cone past a full turn would start overdrawing the ring it already painted. A max of 0 is treated as 100 instead of dividing by zero.

The sweep is the ratio, not the raw value
30 / 60
Step 3
9 / 10

What screen readers hear

The ring is a real role="progressbar" with aria-valuenow / aria-valuemin / aria-valuemax, and label is required — a progressbar with no name announces a number and never says what the number is about.

By default assistive tech hears the percentage, which is right when the percentage is the point. When it is not, say what the number means:

<x-wirekit::radial-progress
    :value="18.2"
    :max="25"
    label="Storage"
    value-text="18.2 GB of 25 GB used"
>
    73%
</x-wirekit::radial-progress>

The center is a slot and starts empty — a bare percentage repeated inside every ring is noise that aria-valuetext already carries. Put something there when it earns its place: a real figure, a unit, an icon.

Center content that earns its place

18.2 GB

of 25

Threshold coloring

warn and danger are fractions of max. Crossing one overrides the declared intent:

{{-- Turns warning at 80%, danger at 100% --}}
<x-wirekit::radial-progress :value="85" label="Quota" intent="primary" :warn="0.8" :danger="1.0" />

The same ring, same intent="primary", at three values — the color follows the threshold, not the declared intent:

Below, at, and over the threshold
45%
85%
Full

A ring that is over its limit but still drawn in your brand color is telling the reader the wrong thing, whatever the author declared — so the threshold wins.

Thresholds are opt-in: without them, 99% of a download is not a warning.

The thresholds mirror usage meter, so a dashboard reads the same in both shapes.

Color is never the only signal Threshold coloring must not be the only thing that says a limit was crossed. A reader who cannot separate the warning hue from the brand one sees an identical ring. Put the state in text too — in the center, in the label, or in a badge beside it.

Props

Prop Type Default Description
value int|float 0 Current value. Clamped to [0, max].
max int|float 100 What the value is out of. A non-positive max is treated as 100.
label string|null null Accessible name. Required for the ring to mean anything.
valueText string|null null What assistive tech reads instead of the percentage.
size string 'md' sm, md, lg, xl.
intent string 'primary' primary, accent, success, warning, danger, info, neutral.
warn float|null null Fraction of max that turns the ring warning.
danger float|null null Fraction of max that turns the ring danger.
animate bool false Sweep the fill from empty to its value on first paint, and animate later value changes. Gated by prefers-reduced-motion.
scope string|null null Class-scope override.

Accessibility

  • A real role="progressbar" with aria-valuenow / aria-valuemin / aria-valuemax and an aria-valuetext that says what the value means.
  • aria-valuetext and the visible center are the same string when you slot the same text, so they cannot disagree.
  • The sweep transition is dropped under prefers-reduced-motion.
  • info shares the accent fill: there is no distinct info base token, and the linear bar makes the same call.

Keyboard Interaction

None. A progress ring reports; it is not a control. Nothing here is focusable.

Design Tokens

The ring reads two variables you can set per instance or per theme:

Variable Purpose
--wk-radial-size Outer diameter. The size prop sets it.
--wk-radial-thickness Ring thickness. Scales with size; override for a hairline or a heavy ring.
/* A thinner ring everywhere */
.wk-radial {
    --wk-radial-thickness: 0.25rem;
}

The track uses --color-wk-bg-muted — the same track color the linear bar uses, so the two shapes read as one family.