Skip to content

role progressbar

Progress bar draft v0.1.0

Shows how far a task has progressed, or that it is in progress when its length is unknown.

A progress bar fills a track from start to end as a task advances: an upload, an export, the steps of a setup. It has a visible label naming the task and, when the progress can be measured, an optional value in percent. When the length is unknown, the bar is indeterminate: a segment moves along the track to say that something is happening. It only reports: a progress bar is never a control.

Web implementation

Root .ds-progress-bar on a <div>. Properties map to attributes: size → data-size

packages/components/src/components/progress-bar/progress-bar.css
/*
 * Progress bar — vanilla CSS reference implementation.
 * Spec:   packages/components/specs/components/progress-bar.spec.json
 * Tokens: packages/tokens/dist/css/components/progress-bar.css (generated from packages/tokens/src/components/progress-bar.tokens.json)
 *
 * Rules:
 *  - Only --progress-bar-* custom properties may appear here. No raw values, no semantic tokens.
 *  - The root is a wrapper (label row, then the track); the native progress element is the track,
 *    drawn by the component; the indicator is the browser's value pseudo-element (WebKit, Gecko).
 *  - Size is a data attribute on the root. The indeterminate state is the native one (a progress
 *    without value); [data-state="indeterminate"] on the root forces its look for review. Both animate
 *    a segment along the track, at a multiple of the motion token's duration, and stop under
 *    prefers-reduced-motion: the segment then rests at the start of the track.
 */

.ds-progress-bar {
  --_track-height: var(--progress-bar-track-height-medium);

  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--progress-bar-container-gap);
  min-inline-size: 0;
}

.ds-progress-bar[data-size="small"] {
  --_track-height: var(--progress-bar-track-height-small);
}

.ds-progress-bar__header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  column-gap: var(--progress-bar-container-column-gap);
}

.ds-progress-bar__label {
  color: var(--progress-bar-label-color);
  font-family: var(--progress-bar-label-typography-font-family);
  font-size: var(--progress-bar-label-typography-font-size);
  font-weight: var(--progress-bar-label-typography-font-weight);
  line-height: var(--progress-bar-label-typography-line-height);
  letter-spacing: var(--progress-bar-label-typography-letter-spacing);
}

.ds-progress-bar__value {
  color: var(--progress-bar-value-color);
  font-family: var(--progress-bar-value-typography-font-family);
  font-size: var(--progress-bar-value-typography-font-size);
  font-weight: var(--progress-bar-value-typography-font-weight);
  line-height: var(--progress-bar-value-typography-line-height);
  letter-spacing: var(--progress-bar-value-typography-letter-spacing);
  font-variant-numeric: tabular-nums;
}

/* ---- Track and indicator ---------------------------------------------------- */

.ds-progress-bar__track {
  box-sizing: border-box;
  display: block;
  inline-size: 100%;
  block-size: var(--_track-height);
  margin: 0;
  border: none;
  border-radius: var(--progress-bar-track-border-radius);
  background-color: var(--progress-bar-track-background);
  color: var(--progress-bar-indicator-background);
  overflow: hidden;
  appearance: none;
  -webkit-appearance: none;
}
.ds-progress-bar__track::-webkit-progress-bar {
  background-color: var(--progress-bar-track-background);
  border-radius: var(--progress-bar-track-border-radius);
}
.ds-progress-bar__track::-webkit-progress-value {
  background-color: var(--progress-bar-indicator-background);
  border-radius: var(--progress-bar-indicator-border-radius);
  transition: inline-size var(--progress-bar-indicator-transition-duration) var(--progress-bar-indicator-transition-easing);
}
.ds-progress-bar__track::-moz-progress-bar {
  background-color: var(--progress-bar-indicator-background);
  border-radius: var(--progress-bar-indicator-border-radius);
}

/* Indeterminate: no value bar; a segment a third of the track sweeps from start to end. The duration is
   a multiple of the normal motion duration (there is no long duration token yet, see the spec).
   WebKit paints the track in ::-webkit-progress-bar over the element, so the segment is drawn there too. */
.ds-progress-bar__track:where(:indeterminate)::-webkit-progress-value,
.ds-progress-bar:where([data-state="indeterminate"]) .ds-progress-bar__track::-webkit-progress-value {
  opacity: 0;
}
.ds-progress-bar__track:where(:indeterminate)::-moz-progress-bar,
.ds-progress-bar:where([data-state="indeterminate"]) .ds-progress-bar__track::-moz-progress-bar {
  background-color: transparent;
}
.ds-progress-bar__track:where(:indeterminate),
.ds-progress-bar:where([data-state="indeterminate"]) .ds-progress-bar__track,
.ds-progress-bar__track:where(:indeterminate)::-webkit-progress-bar,
.ds-progress-bar:where([data-state="indeterminate"]) .ds-progress-bar__track::-webkit-progress-bar {
  background-image: linear-gradient(to right, transparent 0%, transparent 33%, currentColor 33%, currentColor 66%, transparent 66%);
  background-size: 300% 100%;
  background-position: 100% 0;
  animation: ds-progress-bar-indeterminate calc(var(--progress-bar-indicator-transition-duration) * 6) var(--progress-bar-indicator-transition-easing) infinite;
}

@keyframes ds-progress-bar-indeterminate {
  from { background-position: 100% 0; }
  to { background-position: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .ds-progress-bar__track,
  .ds-progress-bar__track::-webkit-progress-bar,
  .ds-progress-bar__track::-webkit-progress-value {
    animation: none;
    transition: none;
    background-position: 100% 0;
  }
}
packages/components/src/components/progress-bar/progress-bar.template.mjs
/**
 * Progress bar — HTML template for the web implementation.
 * Pure function of the spec properties (packages/components/specs/components/progress-bar.spec.json) -> HTML string.
 * A native progress element named by its label: the browser exposes the progressbar role and the
 * value; without a value the bar is indeterminate.
 *
 * Extra props: `id` names the progress element (unique in the page; without it instances are
 * numbered in render order).
 * Extra sandbox-only prop: `state` forces a visual state through data-state on the root.
 */
import { escapeHtml, escapeAttr } from '../../lib/template-utils.mjs';

export const meta = { id: 'progress-bar', selector: '.ds-progress-bar' };

let count = 0;

export function render(props = {}) {
  const { label = 'Progress', value = undefined, max = 100, showValue = false, size = 'medium', state = null, id = null } = props;
  const progressId = id ?? `ds-progress-bar-${++count}`;
  const maximum = Number(max) > 0 ? Number(max) : 100;
  const determinate = value !== undefined && value !== null && value !== '' && !Number.isNaN(Number(value));
  const current = determinate ? Math.min(maximum, Math.max(0, Number(value))) : null;
  const rootAttrs = ['class="ds-progress-bar"', `data-size="${escapeAttr(size)}"`];
  if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);
  const trackAttrs = ['class="ds-progress-bar__track"', 'data-part="track"', `id="${escapeAttr(progressId)}"`, `max="${escapeAttr(maximum)}"`];
  if (determinate) trackAttrs.push(`value="${escapeAttr(current)}"`);
  const percent = determinate ? `${Math.round((current / maximum) * 100)}%` : null;
  const header = [`<label class="ds-progress-bar__label" data-part="label" for="${escapeAttr(progressId)}">${escapeHtml(label)}</label>`];
  if (showValue && determinate) header.push(`<span class="ds-progress-bar__value" data-part="value">${escapeHtml(percent)}</span>`);
  return `<div ${rootAttrs.join(' ')}><div class="ds-progress-bar__header">${header.join('')}</div><progress ${trackAttrs.join(' ')}>${determinate ? escapeHtml(percent) : ''}</progress></div>`;
}

No other platform implementation recorded yet. Platform token outputs live under Platforms.