Skip to content

Progress ring

A circular counterpart to the linear progress bar. There is no native circular-progress element, so this is a role="progressbar" <div> drawn purely in CSS: a conic-gradient sweep (the value) with the centre punched out by a radial mask, leaving a ring — with the numeric label in the hole. No JavaScript: set the --re-progress-value custom property (0–100).

Live example
<div class="row">
  <div
    class="re-progress-ring"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="25"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="25%"
    style="--re-progress-value: 25"
  >
    <span class="re-progress-ring__label" aria-hidden="true">25%</span>
  </div>
  <div
    class="re-progress-ring"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="50"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="50%"
    style="--re-progress-value: 50"
  >
    <span class="re-progress-ring__label" aria-hidden="true">50%</span>
  </div>
  <div
    class="re-progress-ring"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="75"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="75%"
    style="--re-progress-value: 75"
  >
    <span class="re-progress-ring__label" aria-hidden="true">75%</span>
  </div>
  <div
    class="re-progress-ring"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="100"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="100%"
    style="--re-progress-value: 100"
  >
    <span class="re-progress-ring__label" aria-hidden="true">100%</span>
  </div>
</div>

Keep three things in sync: --re-progress-value (draws the arc), the visible label text, and aria-valuenow / aria-valuetext (the value as it reaches assistive tech). If JavaScript drives the ring, it writes all three.

Live example
<div class="row">
  <div
    class="re-progress-ring"
    data-size="sm"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="60"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="60%"
    style="--re-progress-value: 60"
  >
    <span class="re-progress-ring__label" aria-hidden="true">60%</span>
  </div>
  <div
    class="re-progress-ring"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="60"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="60%"
    style="--re-progress-value: 60"
  >
    <span class="re-progress-ring__label" aria-hidden="true">60%</span>
  </div>
  <div
    class="re-progress-ring"
    data-size="lg"
    role="progressbar"
    aria-label="Progress"
    aria-valuenow="60"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuetext="60%"
    style="--re-progress-value: 60"
  >
    <span class="re-progress-ring__label" aria-hidden="true">60%</span>
  </div>
</div>

A spinning arc for work of unknown duration. Omit aria-valuenow and give the ring an aria-label instead — there is no value to report, only a name. The spin respects prefers-reduced-motion.

Live example
<div
  class="re-progress-ring"
  role="progressbar"
  data-indeterminate
  aria-label="Loading"
></div>
  • Role & name — the ring is a role="progressbar", which must have an accessible name (aria-label, or aria-labelledby pointing at nearby text) saying what is progressing. The % is the value, not the name, so it can’t stand in for it — a nameless progressbar is a WCAG 1.1.1 failure.
  • Valuearia-valuenow / aria-valuemin / aria-valuemax carry the value; aria-valuetext gives it a human form (“70%”). The visible % label is aria-hidden (the value already reaches AT through the ARIA attributes) but stays in the DOM so it survives forced colors.
  • Forced colors (Windows High Contrast) — a conic-gradient is a background image, which the UA drops, so the arc disappears. The fallback keeps the numeric % on screen and draws a neutral CanvasText track ring, so the control still reads as a ring and the value is still visible. (Rendering the filled level under HCM would need an SVG stroke; that’s intentionally out of scope here.)
  • Motion — the indeterminate spin is paused under prefers-reduced-motion: reduce.

See the accessibility guide for the project-wide approach.