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
| Component | Use it for |
|---|---|
| Progress | Known-fraction work: uploads, migrations. Set width inline; add aria-valuenow. |
| Spinner | Unknown duration. Keeps spinning under reduced-motion (functional), just slower. |
| Skeleton | First paint of async views. Mirror the eventual layout's shapes. |
| Meter | Value against a cap. Consumer applies -warning/-danger at domain thresholds. |
Examples
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
| Class | Effect |
|---|---|
.soma-progress | The track — 8px, full width, neutral-subtle fill. Carries the role="progressbar" ARIA contract. |
.soma-progress-bar | The fill: width set inline (or by the driver); animates width changes over 250ms (no transition under reduced motion). |
.soma-progress-small | 4px height for dense contexts. |
.soma-progress-success / -warning / -danger | Semantic bar color (the track stays neutral). Never the sole channel; pair with visible text. |
.soma-progress-indeterminate | Unknown-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-static | Suppresses 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
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
.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}}`;
});