ProgressATM
A progress bar showing how much of a task is done. Built on the native <progress> element, in any color, with a live readout - a percentage, "x / n" or your own template - above or inside the bar. The value is state: jump it in steps, let it glide there linearly, and reset, step or play it from plain buttons (commandfor + command="--reset") - no script of your own.
On this page (15)
§Determinate
A .progress-field pairs the bar with a label and an <output class='progress-value'> - the script writes the percent into it and keeps it in sync with every change.
§Values
Different completion amounts.
§Colors
data-tone paints the fill: success, warning, info, destructive - any other color through style='--progress-color: …'. data-tone='auto' follows the value: red below a third, amber below two thirds, green above.
§Labels
data-format on the output: percent (default), fraction (x / n) or value; data-template fills in the value, max and percent tokens (in curly braces). A fraction or template also becomes the bar's aria-valuetext, so a screen reader says '3 / 8', not '38%'.
§Label inside the bar
data-label='inside' on the field puts the output in the bar (1.25rem tall). Over the fill the text switches to the fill's own foreground - a copy clipped to the filled part - so it reads on both halves.
§Step by step
Jumps: --increment / --decrement move the bar by data-step (10% by default) at once, --reset empties it - plain buttons with commandfor + command, no script. 'Run' steps through a task in 10% jumps via the State API.
§Linear (interpolated)
Glides: --play runs the bar to the end linearly over data-duration (ms) - the readout counts every frame; --pause holds it, --reset starts over. setState('default', with value and duration) interpolates to any value.
§Striped
data-striped lays diagonal bands over the fill; data-striped='animated' moves them - a busy task that still has a known value.
§Indeterminate
No value attribute: the animated sweep for an unknown amount - the readout shows '…' (or its data-indeterminate text).
§In cards
Progress fields composed with .card for a dashboard - each readout bound to its bar.
§Sizes
{'Bar thickness scales via '}data-size{' on the '}{''}{' - the default equals '}md.
§Right to left
The bar fills from the right in dir='rtl', and the two-color inside label clips from the right too.
§States
Named states via the shared State API, driven per instance through the bound api (the bar's state name follows its value):
default- determinate:{ value }jumps there,{ value, duration }glides there linearly (ms), no config restores the authored value; optional{ max }indeterminate- the value removed: the moving sweep for an unknown amountcomplete- value = max ({ duration }glides there);progress:completedfires, as it does whenever a value reaches max
Every change fires progress:change (detail: { value, max, percent }). The same moves are button commands - commandfor="id" command="--reset" (--increment, --decrement, --play, --pause, --complete, --indeterminate) - or progress:<command> events on the bar.
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/progress-{state}.png.
Machine contract - verified against progress.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | number | — | 66 | Completion (0–max); set through the State API (setState('default', { value })), so the readouts, level and events follow. |
indeterminate | boolean | true, false | false | No value - the moving sweep (setState('indeterminate')). |
complete | boolean | true, false | false | Value = max (setState('complete')); progress:completed fires. |
size | enum | xs, sm, md, lg, xl | "md" | Bar thickness. |
tone | enum | primary, success, warning, info, destructive, auto | "primary" | Fill color; auto follows the value. |
§CSS view file
WebKit (Chrome, Safari, Edge) and Firefox paint the bar through their own pseudo-elements; both read the same --_fill.
/* -- Progress component --------------------------------------------- */@layer components { /* The fill resolves once, here: data-tone (--_c) > the public --progress-color > --primary. Everything below paints from --_fill. */ .progress { --_fill: var(--_c, var(--progress-color, var(--primary))); --_stripes: none; appearance: none; -webkit-appearance: none; -moz-appearance: none; width: 100%; height: 0.5rem; border: none; border-radius: 9999px; overflow: hidden; background-color: var(--secondary); accent-color: var(--_fill); vertical-align: middle; /* -- Sizes: bar thickness scale; default (no attribute) == md. */ &[data-size="xs"] { height: 0.25rem; } &[data-size="sm"] { height: 0.375rem; } &[data-size="md"] { height: 0.5rem; } &[data-size="lg"] { height: 0.75rem; } &[data-size="xl"] { height: 1rem; } /* -- Tones: success / warning / info / destructive; any other color via style="--progress-color: …". auto follows the value: the runtime (progress.js) sets data-level low (< 34%) / mid / high. */ &[data-tone="success"] { --_c: oklch(0.6 0.15 150); } &[data-tone="warning"] { --_c: oklch(0.75 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.75 0.16 70); } &[data-level="high"] { --_c: oklch(0.6 0.15 150); } } /* -- Stripes: diagonal bands over the fill; "animated" moves them */ &[data-striped] { --_stripes: repeating-linear-gradient( -45deg, color-mix(in oklch, white 22%, transparent) 0 0.375rem, transparent 0.375rem 0.75rem ); } } /* WebKit (Chrome, Safari, Edge) */ .progress::-webkit-progress-bar { background-color: var(--secondary); border-radius: 9999px; } .progress::-webkit-progress-value { background: var(--_stripes), var(--_fill); background-size: 1.06rem 1.06rem, auto; border-radius: 9999px; } .progress[data-striped="animated"]::-webkit-progress-value { animation: progress-stripes 700ms linear infinite; } /* Firefox */ .progress::-moz-progress-bar { background: var(--_stripes), var(--_fill); background-size: 1.06rem 1.06rem, auto; border-radius: 9999px; } .progress[data-striped="animated"]::-moz-progress-bar { animation: progress-stripes 700ms linear infinite; } @keyframes progress-stripes { to { background-position: 1.06rem 0, 0 0; } } /* Indeterminate state */ .progress:indeterminate { background: linear-gradient( 90deg, var(--secondary) 0%, var(--_fill) 50%, var(--secondary) 100% ); background-size: 200% 100%; animation: progress-indeterminate 1.5s linear infinite; } .progress:indeterminate::-webkit-progress-bar { background-color: transparent; } .progress:indeterminate::-moz-progress-bar { background-color: transparent; } @keyframes progress-indeterminate { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* -- Field: a label + a live value readout with the bar --------------- <div class="progress-field"> <div class="progress-header"><span class="progress-label">…</span> <output class="progress-value" for="bar-id"></output></div> <progress class="progress" id="bar-id" …></progress> </div> progress.js writes the value into every output (data-format: percent | fraction | value, or a data-template). */ .progress-field { display: flex; flex-direction: column; gap: 0.375rem; width: 100%; } .progress-header { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; } .progress-label { font-size: 0.875rem; font-weight: 500; color: var(--foreground); } .progress-value { font-size: 0.875rem; font-variant-numeric: tabular-nums; color: var(--muted-foreground); white-space: nowrap; } .progress-hint { font-size: 0.75rem; color: var(--muted-foreground); } /* -- Label inside the bar: data-label="inside" on the field (bar + output only). The output spans the bar; its text reads in the foreground over the track and - through a copy clipped to the filled part (::after, data-text + --progress-pct set by progress.js) - in the fill's own foreground over the fill. */ .progress-field[data-label="inside"] { display: grid; & > .progress { grid-area: 1 / 1; height: 1.25rem; &[data-size="lg"] { height: 1.5rem; } &[data-size="xl"] { height: 1.75rem; } } & > .progress-value { grid-area: 1 / 1; position: relative; display: grid; place-items: center; font-size: 0.75rem; font-weight: 600; color: var(--foreground); pointer-events: none; &::after { content: attr(data-text); position: absolute; inset: 0; display: grid; place-items: center; color: var(--primary-foreground); clip-path: inset(0 calc(100% - var(--progress-pct, 0%)) 0 0); } } } /* the tones are light fills: dark text reads better on them */ .progress-field[data-label="inside"]:has(> .progress:is([data-tone="warning"], [data-tone="auto"][data-level="mid"])) > .progress-value::after { color: oklch(0.25 0.05 70); } .progress-field[data-label="inside"]:has(> .progress:is([data-tone="success"], [data-tone="info"], [data-tone="destructive"], [data-tone="auto"]:not([data-level="mid"]))) > .progress-value::after { color: white; } .progress-field[data-label="inside"]:dir(rtl) > .progress-value::after { clip-path: inset(0 0 0 calc(100% - var(--progress-pct, 0%))); }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend firing. (progress.js also skips its value tween then - the bar jumps straight to the target.) */@media (prefers-reduced-motion: reduce) { @layer components { .progress, .progress::-webkit-progress-value, .progress::-moz-progress-bar { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}@media (prefers-contrast: more) { @layer components { .progress { outline: 1px solid var(--foreground); outline-offset: 1px; } .progress-value { color: var(--foreground); } }}@media (forced-colors: active) { @layer components { .progress { forced-color-adjust: none; background: Canvas; outline: 1px solid CanvasText; } .progress::-webkit-progress-bar { background: Canvas; } .progress::-webkit-progress-value { background: Highlight; } .progress::-moz-progress-bar { background: Highlight; } .progress-field[data-label="inside"] > .progress-value { color: CanvasText; } .progress-field[data-label="inside"] > .progress-value::after { color: HighlightText; } }}§JS view file
/* -- Progress component --------------------------------------------- */// The bar itself is the native <progress> (CSS paints it). This module adds// what HTML can't: live value readouts (every <output class="progress-value"// for="id">, as a percent, a fraction "x / n" or a template), the data-level// the auto tone reads, linear interpolation toward a new value, the// declarative button commands (commandfor + command="--reset" …) and 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 moving sweep); complete = value == max. */const progressStates = ['default', 'indeterminate', 'complete'];const SELECTOR = 'progress.progress';const reducedMotion = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;const maxOf = (el) => el.max || 1;const clamp = (el, v) => Math.max(0, Math.min(maxOf(el), Number(v) || 0));/** Whole numbers stay whole; fractions keep one decimal (a tween passes 42.7). */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 readout text for one output (data-format / data-template on the output). */function text(out, el) { const v = el.position < 0 ? null : el.value; const max = maxOf(el); if (v == null) return out.dataset.indeterminate ?? '…'; const pct = v / max; const tpl = out.dataset.template; if (tpl) { return tpl .replaceAll('{value}', numFmt.format(Math.round(v))) .replaceAll('{max}', numFmt.format(max)) .replaceAll('{percent}', pctFmt.format(pct)); } switch (out.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(pct); }}/** Outputs bound to a bar: output[for~=id] anywhere, plus .progress-value in its field. */function outputsOf(el) { const outs = new Set(); if (el.id) document.querySelectorAll(`output.progress-value[for~="${CSS.escape(el.id)}"]`).forEach((o) => outs.add(o)); el.closest('.progress-field')?.querySelectorAll('.progress-value').forEach((o) => { if (!o.htmlFor?.value || (el.id && o.htmlFor.contains(el.id))) outs.add(o); }); return [...outs];}/** Paint everything derived from the value: level, complete flag, readouts, aria-valuetext. */function paint(el) { const indeterminate = el.position < 0; const pct = indeterminate ? 0 : el.value / maxOf(el); el.dataset.level = pct < 0.34 ? 'low' : pct < 0.67 ? 'mid' : 'high'; el.toggleAttribute('data-complete', !indeterminate && el.value >= maxOf(el)); let spoken = ''; for (const out of outputsOf(el)) { const t = text(out, el); out.value = t; out.dataset.text = t; out.style.setProperty('--progress-pct', `${(pct * 100).toFixed(2)}%`); if (out.dataset.format === 'fraction' || out.dataset.template) spoken ||= t; } // a fraction / template is what the reader means - say it, not the percent if (spoken) el.setAttribute('aria-valuetext', spoken); else el.removeAttribute('aria-valuetext');}function stopTween(el) { if (el._raf) cancelAnimationFrame(el._raf); el._raf = 0;}/** Settle on a value: the state name follows it (max → complete), events fire. */function commit(el, v, emit = true) { const before = el.dataset.stateName; el.value = v; paint(el); const done = v >= maxOf(el); el.dataset.stateName = done ? 'complete' : 'default'; if (emit) el.dispatchEvent(new CustomEvent('progress:change', { bubbles: true, detail: { value: el.value, max: el.max, percent: el.value / maxOf(el) } })); if (done && before !== 'complete') el.dispatchEvent(new CustomEvent('progress:completed', { bubbles: true }));}/** Linear interpolation from the current value to `to` over `duration` ms * (time-based, so a throttled frame never slows the total). */function tween(el, to, duration) { stopTween(el); const from = el.position < 0 ? 0 : el.value; if (!(duration > 0) || reducedMotion() || from === to) return commit(el, to); const t0 = performance.now(); el.dataset.stateName = 'default'; el.toggleAttribute('data-running', true); const frame = (now) => { const k = Math.min(1, (now - t0) / duration); if (k < 1) { el.value = round(from + (to - from) * k); paint(el); el._raf = requestAnimationFrame(frame); } else { el._raf = 0; el.removeAttribute('data-running'); 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.max = Number(config.max); const to = config.value != null ? clamp(el, config.value) : clamp(el, el._authored ?? 0); if (config.duration > 0) tween(el, to, config.duration); else { stopTween(el); el.removeAttribute('data-running'); commit(el, to); } break; } case 'indeterminate': stopTween(el); el.removeAttribute('data-running'); el.removeAttribute('value'); paint(el); el.dataset.stateName = 'indeterminate'; break; case 'complete': if (config.duration > 0) tween(el, maxOf(el), config.duration); else { stopTween(el); el.removeAttribute('data-running'); commit(el, maxOf(el)); } break; }}/** Registry-level API; pass the <progress class="progress"> explicitly. Unknown names throw. */export const progressApi = { setState(el, stateName, config = {}) { if (!progressStates.includes(stateName)) { throw new Error(`progress: unknown state "${stateName}" (supported: ${progressStates.join(', ')})`); } el._stateConfig = config; triggerStateChange(el, stateName, config); }, getState(el) { const indeterminate = el.position < 0; return { name: el.dataset.stateName || 'default', config: { ...el._stateConfig, value: indeterminate ? null : el.value, max: el.max, percent: indeterminate ? null : el.value / maxOf(el) }, }; },};df$.progressApi = progressApi;df$.progressStates = progressStates;/** The command vocabulary (commandfor="bar-id" command="--…", or an event * progress:<name> dispatched on the bar). */function run(el, command) { const now = el.position < 0 ? 0 : el.value; switch (command) { case 'reset': progressApi.setState(el, 'default', { value: 0 }); break; case 'increment': progressApi.setState(el, 'default', { value: now + stepOf(el) }); break; case 'decrement': progressApi.setState(el, 'default', { value: now - stepOf(el) }); break; case 'complete': progressApi.setState(el, 'complete'); break; case 'indeterminate': progressApi.setState(el, 'indeterminate'); break; // linear run from here to max over data-duration (remaining share of it) case 'play': { // a full bar starts over from 0 if (now >= maxOf(el)) el.value = 0; const rest = 1 - (el.position < 0 ? 0 : el.value) / maxOf(el); progressApi.setState(el, 'default', { value: maxOf(el), duration: durationOf(el) * rest }); break; } case 'pause': stopTween(el); el.removeAttribute('data-running'); commit(el, el.value); break; default: return false; } return true;}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) => progressApi.setState(el, stateName, config), getState: () => progressApi.getState(el), }; el._authored = el.position < 0 ? null : el.value; el.dataset.stateName = el.position < 0 ? 'indeterminate' : el.value >= maxOf(el) ? 'complete' : 'default'; // Invoker Commands: <button commandfor="id" command="--reset"> el.addEventListener('command', (e) => { const c = String(e.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.__progressCommandInit) { document.__progressCommandInit = 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)); });}// A value / max written straight to the element (el.value = 40, or an// attribute) repaints the readouts too.new MutationObserver((records) => { for (const r of records) { const el = r.target; if (el instanceof HTMLProgressElement && el.matches(`${SELECTOR}[data-init]`) && !el._raf) { paint(el); el.dataset.stateName = el.position < 0 ? 'indeterminate' : el.value >= maxOf(el) ? 'complete' : 'default'; } }}).observe(document, { attributes: true, subtree: true, attributeFilter: ['value', 'max'] });init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub