Radial ProgressATM
A progress ring with its number in the middle - storage used, a score, a goal reached. CSS draws the arc (one conic-gradient masked to a ring, a round cap on the leading edge); the optional script makes it state: any color, a percent, "x / n" or your own label, values that jump in steps or glide there linearly, and the same reset / step / play buttons as the progress bar (commandfor + command="--reset").
On this page (13)
§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-valuenowremoved: the spinning arccomplete- value = max ({ duration }glides there);progress:completedfires
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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | number | — | 70 | The value (0 – aria-valuemax); set through the State API (setState('default', { value })), observed from aria-valuenow. |
indeterminate | boolean | true, false | false | No value - the spinning arc (setState('indeterminate')). |
complete | boolean | true, false | false | Value = max (setState('complete')); progress:completed fires. |
size | enum | sm, md, lg | "md" | Ring diameter. |
tone | enum | primary, 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