Progress

Summary

Progress is the determinate bar for known-fraction work: semantic color variants, a small size, an indeterminate sweep, a static (no-animation) form, and the Soma.progress driver that keeps the bar and its ARIA value in step. Indeterminate waits are the Spinner; loading placeholders are the Skeleton; consumption against a bound is the Meter.

When to use

ComponentUse it for
ProgressKnown-fraction work: uploads, migrations. Set width inline; add aria-valuenow.
SpinnerUnknown duration. Keeps spinning under reduced-motion (functional), just slower.
SkeletonFirst paint of async views. Mirror the eventual layout's shapes.
MeterValue against a cap. Consumer applies -warning/-danger at domain thresholds.

Examples

Default

8px track, primary bar:

Width set inline; aria-valuenow matches it.

Small

soma-progress-small · 4px:

For dense contexts: table cells, list rows, card footers.

Semantic colors

-success · -warning · -danger:

Color the bar, not the meaning — pair with visible text when the state matters.

Indeterminate

Unknown-duration sweep:

No aria-valuenow — a progressbar without one reads as indeterminate to AT. The sweep freezes under reduced motion.

Static

soma-progress-static · no transition:

Restoring a known value on page load: the bar appears at 40% instead of animating from 0.

Live driver

Soma.progress().update():

Last soma-progress-update: (none). One update() call sets bar width, data-value and aria-valuenow together.

HTML

The determinate contract — role="progressbar", an accessible name, the value triple, and the bar's width inline. Server-rendered pages emit exactly this and need no JS:

<div class="soma-progress" role="progressbar" aria-label="Storage used"
     aria-valuenow="62" aria-valuemin="0" aria-valuemax="100">
  <div class="soma-progress-bar" style="width: 62%"></div>
</div>

Size and semantic variants combine freely on the root. A small danger bar for a dense table cell:

<div class="soma-progress soma-progress-small soma-progress-danger"
     role="progressbar" aria-label="Battery"
     aria-valuenow="8" aria-valuemin="0" aria-valuemax="100">
  <div class="soma-progress-bar" style="width: 8%"></div>
</div>

Indeterminate, statically: add the class, drop aria-valuenow and the inline width (a progressbar without a value already reads as indeterminate to AT):

<div class="soma-progress soma-progress-indeterminate" role="progressbar" aria-label="Scanning">
  <div class="soma-progress-bar"></div>
</div>

When restoring a known value on page load, -static suppresses the width transition so the bar paints at its value instead of animating from 0 (see Restoring a value on load):

<div class="soma-progress soma-progress-static" role="progressbar"
     aria-label="Migration" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100">
  <div class="soma-progress-bar" style="width: 40%"></div>
</div>

CSS classes

ClassEffect
.soma-progressThe track — 8px, full width, neutral-subtle fill. Carries the role="progressbar" ARIA contract.
.soma-progress-barThe fill: width set inline (or by the driver); animates width changes over 250ms (no transition under reduced motion).
.soma-progress-small4px height for dense contexts.
.soma-progress-success / -warning / -dangerSemantic bar color (the track stays neutral). Never the sole channel; pair with visible text.
.soma-progress-indeterminateUnknown-duration work: a 40%-wide bar sweeps the track (1.4s loop). Soma.progress().setIndeterminate() toggles it; static markup can set it directly. Under reduced motion the sweep freezes. The partial static bar still reads as "busy".
.soma-progress-staticSuppresses the width transition. Use when restoring a known value on page load so the bar doesn't animate from 0.

JavaScript

Progress renders fine as static CSS: server-rendered markup needs no JS at all, and there is no auto-init. The driver exists for the dynamic case: one call keeps the bar width and the ARIA value in step, instead of two hand-written mutations that can drift apart.

Constructor

MemberDescription
Soma.progress(elOrSelector)Get or create the singleton driver for a .soma-progress element / selector. Throws when nothing matches, and when the element is missing the soma-progress class. On first bind it fills in ARIA scaffolding: role="progressbar" (only when no role is set) plus aria-valuemin="0" / aria-valuemax="100".

Instance methods

MemberDescription
.update(value)Set the fraction, 0..1 (out-of-range values clamp, non-numbers count as 0). One call clears any indeterminate state, then sets the bar width, data-value (the exact fraction) and aria-valuenow (the rounded percent) together, and fires the update event.
.value()The last driven fraction (0..1), or null when indeterminate. It reads the data-value the driver writes, so a bar only ever set by inline width also reads null until its first update().
.setIndeterminate()Switch to the sweeping unknown-duration state: adds .soma-progress-indeterminate and clears the inline width, data-value and aria-valuenow (a progressbar without a value reads as indeterminate to AT). Fires no event — the next update() resumes determinate mode.

Both mutators return the instance, so calls chain. The update notification is a bubbling soma-progress-update CustomEvent dispatched on the .soma-progress element; e.detail = { value, percent } (value the clamped 0..1 fraction, percent the rounded integer). It is usable directly with addEventListener on the element or any ancestor.

Drive an upload — the browser gives you the fraction, the driver does the rest:

const p = Soma.progress('#upload-progress');

xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) p.update(e.loaded / e.total);
});
xhr.addEventListener('load', () => p.update(1));

Listen for updates (delegated, since the CustomEvent bubbles):

document.getElementById('transfers').addEventListener('soma-progress-update', (e) => {
  // e.target is the .soma-progress that changed
  console.log(e.target.getAttribute('aria-label'), e.detail.percent + '%');
});

Hand off from indeterminate to determinate as soon as the total is known — a scan that counts first, then processes:

const scan = Soma.progress('#scan');
scan.setIndeterminate();               // counting files: duration unknown

const total = await countFiles();
for (let i = 0; i < total; i++) {
  await processFile(i);
  scan.update((i + 1) / total);        // first update() clears the sweep
}

Bind on markup rendered after page load. There is no auto-init to miss; the call is a get-or-create singleton, safe after any DOM update:

container.insertAdjacentHTML('beforeend', renderedRowWithProgressBar);
Soma.progress(container.querySelector('.soma-progress')).update(0.4);

Indeterminate

Two states, one component: determinate (a value) and indeterminate (a sweep). setIndeterminate() and update() toggle between them, and the ARIA follows for free: the driver removes aria-valuenow for the sweep and restores it with the next value, which is exactly the signal assistive technology uses to distinguish the two. Prefer determinate whenever the fraction is computable; use the sweep (or a spinner, when there is no track to show) only while it isn't. Under prefers-reduced-motion the sweep freezes into a partial static bar that still reads as "busy".

Restoring a value on load

The bar transitions width changes over 250ms — right for live updates, wrong for first paint: a server-rendered "resumed at 40%" bar would visibly animate from 0 on every load. The -static class exists for exactly that timing gap. It suppresses the transition, so the bar simply appears at its value.

If the same bar will be driven live afterwards, drop the class once the first paint is done and later updates animate again:

const el = document.querySelector('#migration');
const p = Soma.progress(el);

// Two frames in: the restored width has painted; re-enable animation.
requestAnimationFrame(() => requestAnimationFrame(() => {
  el.classList.remove('soma-progress-static');
}));

// Later, live updates transition smoothly from 40%:
p.update(0.55);

The live examples above, exactly as this page wires them:

const live = Soma.progress('#progress-live');

document.getElementById('progress-advance').addEventListener('click', () => {
  const v = live.value() ?? 0;          // null while indeterminate
  live.update(v >= 1 ? 0 : v + 0.25);  // update() clamps to 0..1
});

document.getElementById('progress-indet').addEventListener('click', () => {
  live.setIndeterminate();               // fires no event — write the readout here
  document.getElementById('progress-live-out').textContent = '(indeterminate)';
});

// One event per update — bar width and ARIA value already in step.
document.getElementById('progress-live').addEventListener('soma-progress-update', (e) => {
  document.getElementById('progress-live-out').textContent =
    `{value: ${e.detail.value}, percent: ${e.detail.percent}}`;
});