Theme
On this page (13)
Component Skill — components/radial-progress/component-skill.md

Native basis

A <div role="progressbar"> with aria-valuenow / aria-valuemin / aria-valuemax - not <progress>, which cannot hold visible text in its center. aria-valuenow is the one source of truth: the script derives --value, the label and the tone from it.

Web Platform APIs

conic-gradient()mask@propertycos() / sin()role="progressbar"commandfor / commandIntl.NumberFormat

Classes

.radial-progress.radial-progress-value.radial-progress-caption

Data attributes

data-tone (success, warning, info, destructive, auto), data-variant="secondary" (muted), data-size (sm, lg), data-format (percent, fraction, value), data-template, data-step, data-duration; custom properties --size, --thickness, --progress-color. Commands: --reset, --increment, --decrement, --play, --pause, --complete, --indeterminate.

§Determinate

aria-valuenow is the value; the script paints the arc (--value), writes the label and keeps both in step with every change.

§Values

The arc starts at twelve o'clock and runs clockwise; a round cap marks its leading edge.

§Colors

data-tone paints arc and label together: success, warning, info, destructive - any other color through --progress-color; data-variant='secondary' is the muted one. data-tone='auto' follows the value: red below a third, amber below two thirds, green above.

§Labels

data-format on the ring: percent (default), fraction (x / n, out of aria-valuemax) or value; data-template fills in the value, max and percent tokens (in curly braces). A .radial-progress-value + .radial-progress-caption pair puts a small caption under the number. Fraction and template also become aria-valuetext.

§Step by step

Jumps: --increment / --decrement move the ring by data-step (10% by default), --reset empties it - plain buttons with commandfor + command. 'Run' steps through a task in 10% jumps via the State API.

§Linear (interpolated)

Glides: --play runs the ring to the end linearly over data-duration (ms) - the number counts every frame; --pause holds it, --reset starts over. setState('default', with value and duration) interpolates to any value.

§Indeterminate

No aria-valuenow: a quarter arc spins for an unknown amount; the label shows its data-indeterminate text.

§Sizes

data-size sm (3.5rem) · default (5rem) · lg (8rem); any other diameter is style='--size: …'.

§Custom size and thickness

--size and --thickness shape any ring - a hairline gauge, a chunky donut; markup inside the ring (an icon) is left as it is.

§In cards

Rings sit on the text baseline, so they compose into stat tiles without a wrapper; data-tone='auto' flags the one that runs out.

§States

Named states via the shared State API, driven per instance through the bound api (the ring's state name follows its value) - the same contract as the progress bar:

  • default - determinate: { value } jumps there, { value, duration } glides there linearly (ms), no config restores the authored value; optional { max }
  • indeterminate - aria-valuenow removed: the spinning arc
  • complete - value = max ({ duration } glides there); progress:completed fires

Every change fires progress:change (detail: { value, max, percent }). Button commands: commandfor="id" command="--reset" (--increment, --decrement, --play, --pause, --complete, --indeterminate) - or progress:<command> events on the ring. Writing aria-valuenow directly repaints it too.

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/radial-progress-{state}.png.

Machine contract - verified against radial-progress.schema.json by bun run verify:

StateTypeValuesDefaultDescription
valuenumber—70The value (0 – aria-valuemax); set through the State API (setState('default', { value })), observed from aria-valuenow.
indeterminatebooleantrue, falsefalseNo value - the spinning arc (setState('indeterminate')).
completebooleantrue, falsefalseValue = max (setState('complete')); progress:completed fires.
sizeenumsm, md, lg"md"Ring diameter.
toneenumprimary, success, warning, info, destructive, auto"primary"Arc and label color; auto follows the value.

§CSS view file

/* -- Radial Progress component --------------------------------- */
/* Typed custom property: a registered <number> interpolates, so a new
   --value tweens the ring with zero JS. The PUBLIC knob stays the plain
   --value (daisyUI's API); only this private copy is registered - a global
   @property --value would re-type every other --value on the page (the
   countdown digits use one) and any consumer's own. @property is an
   at-rule: no cascade weight, outside @layer components. */
@property --_rp-value {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@layer components {
  /* Public custom properties (set them inline on the element):
       --value      0–100, required — the completion percentage
       --size       outer diameter        (default 5rem)
       --thickness  ring stroke width     (default 10% of --size)
     Colors come from the ring's own `color`, so a variant recolors the arc
     and the label in one declaration. */
  .radial-progress {
    /* the public knob (an inline style="--value:70" overrides it) and its
       registered, animatable copy that everything below paints from */
    --value: 0;
    --_rp-value: var(--value);
    --size: 5rem;
    --thickness: calc(var(--size) / 10);
    /* leading-edge cap travels the stroke centerline: r = (size - thickness)/2 */
    --_rp-angle: calc(var(--_rp-value) * 3.6deg - 90deg);
    position: relative;
    display: inline-grid;
    place-content: center;
    box-sizing: content-box;
    inline-size: var(--size);
    block-size: var(--size);
    border-radius: 9999px;
    vertical-align: middle;
    font-size: 0.875rem;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    text-align: center;
    /* data-tone (--_c) > the public --progress-color > --primary; the arc,
       its cap and the label all paint in currentColor */
    color: var(--_c, var(--progress-color, var(--primary)));
    /* the arc is a function of --value, so the registered property tweens it */
    transition: --_rp-value 600ms ease;
    /* Track + indicator in one painted layer: a conic-gradient (arc up to
       --value, track after it) masked down to a ring, plus a round dot at
       12 o'clock so the arc's start is capped. */
    &::before {
      content: "";
      position: absolute;
      inset: 0;
      border-radius: inherit;
      background:
        radial-gradient(farthest-side, currentColor 98%, #0000)
          top / var(--thickness) var(--thickness) no-repeat,
        conic-gradient(currentColor calc(var(--_rp-value) * 1%), var(--secondary) 0);
      -webkit-mask: radial-gradient(
        farthest-side,
        #0000 calc(99% - var(--thickness)),
        #000 calc(100% - var(--thickness))
      );
      mask: radial-gradient(
        farthest-side,
        #0000 calc(99% - var(--thickness)),
        #000 calc(100% - var(--thickness))
      );
    }
    /* Round cap at the leading edge. Positioned with cos()/sin() rather than
       a compound `transform: rotate() translate()` — individual transform
       properties compose in the wrong order for a polar offset, and the trig
       functions keep the position a pure function of --value (so it tweens). */
    &::after {
      content: "";
      position: absolute;
      inline-size: var(--thickness);
      block-size: var(--thickness);
      border-radius: 9999px;
      background-color: currentColor;
      inset-inline-start: calc(
        50% + cos(var(--_rp-angle)) * (var(--size) - var(--thickness)) / 2 -
          var(--thickness) / 2
      );
      inset-block-start: calc(
        50% + sin(var(--_rp-angle)) * (var(--size) - var(--thickness)) / 2 -
          var(--thickness) / 2
      );
    }
    /* -- Variants (recolor arc + label together) ------------------ */
    &[data-variant="default"] {
      color: var(--primary);
    }
    &[data-variant="secondary"] {
      color: var(--muted-foreground);
    }
    &[data-variant="destructive"] {
      color: var(--destructive);
    }
    /* no semantic "success" token exists in the tweakcn shape — literal, same
       green the toast component uses (token boundary rule) */
    &[data-variant="success"] {
      color: oklch(0.65 0.2 145);
    }
    /* -- Tones (same vocabulary as progress): success / warning / info /
       destructive; auto follows the value - radial-progress.js sets
       data-level low (< 34%) / mid / high. Any color: --progress-color. */
    &[data-tone="success"] { --_c: oklch(0.6 0.15 150); }
    &[data-tone="warning"] { --_c: oklch(0.72 0.16 70); }
    &[data-tone="info"] { --_c: oklch(0.6 0.16 250); }
    &[data-tone="destructive"] { --_c: var(--destructive); }
    &[data-tone="auto"] {
      &[data-level="low"] { --_c: var(--destructive); }
      &[data-level="mid"] { --_c: oklch(0.72 0.16 70); }
      &[data-level="high"] { --_c: oklch(0.6 0.15 150); }
    }
    /* -- Label parts: a .radial-progress-value (the number the script
       writes) over an optional .radial-progress-caption ------------- */
    & .radial-progress-value { font-weight: 600; }
    & .radial-progress-caption {
      margin-block-start: 0.25em;
      font-size: 0.6875em;
      font-weight: 400;
      color: var(--muted-foreground);
    }
    /* -- A glide driven by the script paints every frame itself: no CSS
       easing on top (it would lag the counting label) --------------- */
    &[data-running] { transition: none; }
    /* -- Indeterminate: no aria-valuenow (an indeterminate progressbar has
       none) - a quarter arc spins, the cap hides --------------------- */
    &:not([aria-valuenow]) {
      --_rp-value: 25;
      transition: none;
      &::before { animation: radial-progress-spin 1s linear infinite; }
      &::after { display: none; }
    }
    /* -- Sizes ---------------------------------------------------- */
    &[data-size="sm"] {
      --size: 3.5rem;
      font-size: 0.75rem;
    }
    &[data-size="lg"] {
      --size: 8rem;
      font-size: 1.25rem;
    }
  }
  @keyframes radial-progress-spin { to { rotate: 1turn; } }
  /* -- Accessibility -------------------------------------------- */
  /* Suppress motion for users who request it (REQUIRED — AGENTS.md
     "Accessibility CSS"). Near-zero instead of `none` keeps transitionend
     firing for anything awaiting the value tween. */
  @media (prefers-reduced-motion: reduce) {
    .radial-progress {
      transition-duration: 0.01ms !important;
      &::before { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
    }
  }
  /* Thicker stroke and a darker track when more contrast is requested. */
  @media (prefers-contrast: more) {
    .radial-progress {
      --thickness: calc(var(--size) / 6);
      &::before {
        background:
          radial-gradient(farthest-side, currentColor 98%, #0000)
            top / var(--thickness) var(--thickness) no-repeat,
          conic-gradient(currentColor calc(var(--_rp-value) * 1%), var(--border) 0);
      }
    }
  }
  /* Windows High Contrast Mode: gradients are dropped by forced colors, so
     opt out and repaint the ring with system colors instead. */
  @media (forced-colors: active) {
    .radial-progress {
      forced-color-adjust: none;
      color: Highlight;
      &::before {
        background:
          radial-gradient(farthest-side, Highlight 98%, #0000)
            top / var(--thickness) var(--thickness) no-repeat,
          conic-gradient(Highlight calc(var(--_rp-value) * 1%), GrayText 0);
      }
    }
  }
}

§JS view file

/* -- Radial Progress component --------------------------------------- */
// The ring is CSS (radial-progress.css paints --value). This module is the
// optional runtime, mirroring progress.js: it keeps --value, the ARIA value,
// the label (percent / "x / n" / template) and the auto tone's data-level in
// sync with one source of truth - aria-valuenow over aria-valuemax -, glides
// linearly toward a new value, answers the same button commands
// (commandfor + command="--reset" …) and exposes the named State API
// (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
/** default = determinate at a value (as authored, or config.value);
 * indeterminate = no value (the spinning arc); complete = value == max. */
const radialProgressStates = ['default', 'indeterminate', 'complete'];
const SELECTOR = '.radial-progress';
const reducedMotion = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const maxOf = (el) => parseFloat(el.getAttribute('aria-valuemax') || '') || 100;
/** The value, or null while indeterminate (no aria-valuenow). */
const valueOf = (el) => (el.hasAttribute('aria-valuenow') ? parseFloat(el.getAttribute('aria-valuenow')) || 0 : null);
const clamp = (el, v) => Math.max(0, Math.min(maxOf(el), Number(v) || 0));
const round = (v) => Math.round(v * 10) / 10;
const pctFmt = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 });
const numFmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
/** The label text (data-format / data-template on the ring). */
function text(el) {
  const v = valueOf(el);
  const max = maxOf(el);
  if (v == null) return el.dataset.indeterminate ?? '…';
  const tpl = el.dataset.template;
  if (tpl) {
    return tpl
      .replaceAll('{value}', numFmt.format(Math.round(v)))
      .replaceAll('{max}', numFmt.format(max))
      .replaceAll('{percent}', pctFmt.format(v / max));
  }
  switch (el.dataset.format) {
    case 'fraction': return `${numFmt.format(Math.round(v))} / ${numFmt.format(max)}`;
    case 'value': return numFmt.format(Math.round(v));
    default: return pctFmt.format(v / max);
  }
}
/** Where the label goes: a .radial-progress-value child, or the ring itself
 * when it holds plain text (an icon or other markup is left alone). */
function labelTarget(el) {
  const slot = el.querySelector(':scope > .radial-progress-value');
  if (slot) return slot;
  return el.children.length === 0 ? el : null;
}
function paint(el) {
  const v = valueOf(el);
  const pct = v == null ? 0 : v / maxOf(el);
  if (v != null) el.style.setProperty('--value', String(round(pct * 100)));
  el.dataset.level = pct < 0.34 ? 'low' : pct < 0.67 ? 'mid' : 'high';
  el.toggleAttribute('data-complete', v != null && v >= maxOf(el));
  const target = labelTarget(el);
  const t = text(el);
  if (target) target.textContent = t;
  if (el.dataset.format === 'fraction' || el.dataset.template) el.setAttribute('aria-valuetext', t);
  else if (!el._authorValuetext) el.removeAttribute('aria-valuetext');
}
function stopTween(el) {
  if (el._raf) cancelAnimationFrame(el._raf);
  el._raf = 0;
  el.removeAttribute('data-running');
}
function setValue(el, v) {
  if (!el.hasAttribute('aria-valuemin')) el.setAttribute('aria-valuemin', '0');
  el.setAttribute('aria-valuenow', String(round(v)));
  paint(el);
}
/** Settle on a value: the state name follows it (max → complete), events fire. */
function commit(el, v) {
  const before = el.dataset.stateName;
  setValue(el, v);
  const done = v >= maxOf(el);
  el.dataset.stateName = done ? 'complete' : 'default';
  el.dispatchEvent(new CustomEvent('progress:change', { bubbles: true, detail: { value: v, max: maxOf(el), percent: v / maxOf(el) } }));
  if (done && before !== 'complete') el.dispatchEvent(new CustomEvent('progress:completed', { bubbles: true }));
}
/** Linear, time-based interpolation to `to` over `duration` ms. */
function tween(el, to, duration) {
  stopTween(el);
  const from = valueOf(el) ?? 0;
  if (!(duration > 0) || reducedMotion() || from === to) return commit(el, to);
  const t0 = performance.now();
  el.dataset.stateName = 'default';
  el.setAttribute('data-running', '');
  const frame = (now) => {
    const k = Math.min(1, (now - t0) / duration);
    if (k < 1) {
      setValue(el, from + (to - from) * k);
      el._raf = requestAnimationFrame(frame);
    } else {
      stopTween(el);
      commit(el, to);
    }
  };
  el._raf = requestAnimationFrame(frame);
}
const stepOf = (el) => parseFloat(el.dataset.step || '') || maxOf(el) / 10;
const durationOf = (el) => parseFloat(el.dataset.duration || '') || 3000;
function triggerStateChange(el, stateName, config) {
  switch (stateName) {
    case 'default': {
      if (config.max != null) el.setAttribute('aria-valuemax', String(config.max));
      const to = clamp(el, config.value != null ? config.value : el._authored ?? 0);
      if (config.duration > 0) tween(el, to, config.duration);
      else { stopTween(el); commit(el, to); }
      break;
    }
    case 'indeterminate':
      stopTween(el);
      el.removeAttribute('aria-valuenow');
      paint(el);
      el.dataset.stateName = 'indeterminate';
      break;
    case 'complete':
      if (config.duration > 0) tween(el, maxOf(el), config.duration);
      else { stopTween(el); commit(el, maxOf(el)); }
      break;
  }
}
/** Registry-level API; pass the .radial-progress explicitly. Unknown names throw. */
export const radialProgressApi = {
  setState(el, stateName, config = {}) {
    if (!radialProgressStates.includes(stateName)) {
      throw new Error(`radial-progress: unknown state "${stateName}" (supported: ${radialProgressStates.join(', ')})`);
    }
    el._stateConfig = config;
    triggerStateChange(el, stateName, config);
  },
  getState(el) {
    const v = valueOf(el);
    return {
      name: el.dataset.stateName || 'default',
      config: { ...el._stateConfig, value: v, max: maxOf(el), percent: v == null ? null : v / maxOf(el) },
    };
  },
};
df$.radialProgressApi = radialProgressApi;
df$.radialProgressStates = radialProgressStates;
/** The shared progress command vocabulary (see progress.js). */
function run(el, command) {
  const now = valueOf(el) ?? 0;
  switch (command) {
    case 'reset': radialProgressApi.setState(el, 'default', { value: 0 }); break;
    case 'increment': radialProgressApi.setState(el, 'default', { value: now + stepOf(el) }); break;
    case 'decrement': radialProgressApi.setState(el, 'default', { value: now - stepOf(el) }); break;
    case 'complete': radialProgressApi.setState(el, 'complete'); break;
    case 'indeterminate': radialProgressApi.setState(el, 'indeterminate'); break;
    case 'play': {
      // a full ring starts over from 0
      if (now >= maxOf(el)) setValue(el, 0);
      const rest = 1 - (valueOf(el) ?? 0) / maxOf(el);
      radialProgressApi.setState(el, 'default', { value: maxOf(el), duration: durationOf(el) * rest });
      break;
    }
    case 'pause': {
      const held = valueOf(el) ?? 0;
      stopTween(el);
      commit(el, held);
      break;
    }
  }
}
const COMMANDS = ['reset', 'increment', 'decrement', 'complete', 'indeterminate', 'play', 'pause'];
function init() {
  document.querySelectorAll(`${SELECTOR}:not([data-init])`).forEach((el) => {
    el.dataset.init = '';
    el.api = {
      setState: (stateName, config) => radialProgressApi.setState(el, stateName, config),
      getState: () => radialProgressApi.getState(el),
    };
    el._authorValuetext = el.hasAttribute('aria-valuetext') && !el.dataset.format && !el.dataset.template;
    // CSS-only markup may carry only --value: adopt it as the value
    if (!el.hasAttribute('aria-valuenow') && el.style.getPropertyValue('--value') !== '' && el.getAttribute('role') === 'progressbar') {
      el.setAttribute('aria-valuenow', String((parseFloat(el.style.getPropertyValue('--value')) / 100) * maxOf(el)));
    }
    if (!el.hasAttribute('role')) el.setAttribute('role', 'progressbar');
    el._authored = valueOf(el);
    const v = valueOf(el);
    el.dataset.stateName = v == null ? 'indeterminate' : v >= maxOf(el) ? 'complete' : 'default';
    el.addEventListener('command', (e) => {
      const c = String((e as unknown as { command?: string }).command || '');
      if (c.startsWith('--')) run(el, c.slice(2));
    });
    for (const c of COMMANDS) el.addEventListener(`progress:${c}`, () => run(el, c));
    paint(el);
  });
}
// Browsers without the Invoker Commands API: the same buttons, by click.
if (!('commandForElement' in HTMLButtonElement.prototype) && !document.__radialProgressCommandInit) {
  document.__radialProgressCommandInit = true;
  document.addEventListener('click', (e) => {
    const btn = e.target instanceof Element ? e.target.closest('button[commandfor][command^="--"]') : null;
    const el = btn && document.getElementById(btn.getAttribute('commandfor'));
    if (el?.matches(`${SELECTOR}[data-init]`)) run(el, btn.getAttribute('command').slice(2));
  });
}
// aria-valuenow / aria-valuemax written straight to the ring repaint it.
new MutationObserver((records) => {
  for (const r of records) {
    const el = r.target;
    if (el instanceof HTMLElement && el.matches(`${SELECTOR}[data-init]`) && !el._raf) {
      paint(el);
      const v = valueOf(el);
      el.dataset.stateName = v == null ? 'indeterminate' : v >= maxOf(el) ? 'complete' : 'default';
    }
  }
}).observe(document, { attributes: true, subtree: true, attributeFilter: ['aria-valuenow', 'aria-valuemax'] });
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub