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

Native basis

<progress value max> - semantics and the bar come free; <output for> elements carry the readout. The optional script keeps them in sync, animates values and answers button commands.

Web Platform APIs

<progress><output for>commandfor / commandIntl.NumberFormat::-webkit-progress-value::-moz-progress-barclip-path

Classes

.progress.progress-field.progress-header.progress-label.progress-value.progress-hint

Data attributes

On the bar: data-size (xs … xl), data-tone (success, warning, info, destructive, auto), data-striped (animated), data-step, data-duration; on an output: data-format (percent, fraction, value), data-template; on the field: data-label="inside". Commands: --reset, --increment, --decrement, --play, --pause, --complete, --indeterminate.

§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 amount
  • complete - value = max ({ duration } glides there); progress:completed fires, 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:

StateTypeValuesDefaultDescription
valuenumber—66Completion (0–max); set through the State API (setState('default', { value })), so the readouts, level and events follow.
indeterminatebooleantrue, falsefalseNo value - the moving sweep (setState('indeterminate')).
completebooleantrue, falsefalseValue = max (setState('complete')); progress:completed fires.
sizeenumxs, sm, md, lg, xl"md"Bar thickness.
toneenumprimary, 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