---
title: Progress Radial
description: A circular progress ring — determinate, token-driven, with threshold coloring.
category: Data Display
related:
  - /components/progress
  - /components/usage-meter
  - /components/stat
  - /components/sparkline
visibility: guest
draft: false
---

# Progress Radial

A circular progress ring for dashboards and stat tiles, where
[progress](/components/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.

:::preview{title="Radial progress in four sizes and intents"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="25" label="Setup progress" size="sm">25%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="62" label="Storage used">62%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="88" label="Monthly quota" size="lg" intent="warning">88%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="100" label="Seats used" size="xl" intent="danger">Full</x-wirekit::radial-progress>
</x-wirekit::row>
:::

## 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.

:::preview{title="Four sizes"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="66" label="Progress" size="sm">66%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="66" label="Progress" size="md">66%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="66" label="Progress" size="lg">66%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="66" label="Progress" size="xl">66%</x-wirekit::radial-progress>
</x-wirekit::row>
:::

## 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.

:::preview{title="Every intent"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="70" label="Primary" intent="primary" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Accent" intent="accent" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Success" intent="success" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Info" intent="info" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Warning" intent="warning" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Danger" intent="danger" size="sm">70%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="70" label="Neutral" intent="neutral" size="sm">70%</x-wirekit::radial-progress>
</x-wirekit::row>
:::

## 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.

:::preview{title="Sweep the fill on load"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="72" label="Sync progress" intent="primary" size="lg" animate>72%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="90" label="Coverage" intent="success" size="lg" animate>90%</x-wirekit::radial-progress>
</x-wirekit::row>
:::

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:

```blade
{{-- 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.

:::preview{title="The sweep is the ratio, not the raw value"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="30" :max="60" label="Tasks done" size="lg">30 / 60</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="3" :max="4" label="Onboarding steps" intent="success" size="lg">Step 3</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="9" :max="10" label="Credits used" intent="info" size="lg">9 / 10</x-wirekit::radial-progress>
</x-wirekit::row>
:::

## 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:

```blade
<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.

:::preview{title="Center content that earns its place"}
<x-wirekit::row gap="xl" align="center" wrap>
    <x-wirekit::radial-progress :value="18.2" :max="25" label="Storage" value-text="18.2 GB of 25 GB used" intent="info" size="xl">
        <x-wirekit::stack gap="none" style="align-items: center;">
            <x-wirekit::text weight="bold">18.2 GB</x-wirekit::text>
            <x-wirekit::text variant="muted" size="sm">of 25</x-wirekit::text>
        </x-wirekit::stack>
    </x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="100" label="Upload" value-text="Upload complete" intent="success" size="xl">
        <x-wirekit::icon name="check" class="h-7 w-7" />
    </x-wirekit::radial-progress>
</x-wirekit::row>
:::

## Threshold coloring

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

```blade
{{-- 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:

:::preview{title="Below, at, and over the threshold"}
<x-wirekit::row gap="lg" align="center" wrap>
    <x-wirekit::radial-progress :value="45" label="Quota" intent="primary" :warn="0.8" :danger="1.0" size="lg">45%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="85" label="Quota" intent="primary" :warn="0.8" :danger="1.0" size="lg">85%</x-wirekit::radial-progress>
    <x-wirekit::radial-progress :value="100" label="Quota" intent="primary" :warn="0.8" :danger="1.0" size="lg">Full</x-wirekit::radial-progress>
</x-wirekit::row>
:::

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](/components/usage-meter), so a dashboard reads
the same in both shapes.

::: warning 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](/components/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. |

```css
/* 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.
