Progress and spinners
tier 2 — classesFour ways to say “working”. Which one depends on whether you know how far along it is, and whether you know the shape of what is coming.
.progress bar. No
percentage, but a known shape — a table of rows, a card — is a .skeleton. Neither is
a .spinner. Nothing at all yet, in a panel that will fill, is
.empty-state--pending.
Progress bar
Determinate: set the inline width on .progress-bar — the one value
only your app knows. Indeterminate: leave the width off and add
.progress--indeterminate to the track.
Put the number in text as well. A bar is a shape, and a reader who wants to know whether to
wait needs the figure. Under prefers-reduced-motion the indeterminate bar is
filled rather than blanked, so it still says “in progress, no estimate”.
Uploading — 72%
Complete
Slower than expected — still running
Stopped at 34% — quota exceeded
Working — no total known
<div style="display:flex; flex-direction:column; gap:14px; max-width:420px">
<div>
<div class="progress"><div class="progress-bar" style="width:72%"></div></div>
<p class="form-hint">Uploading — 72%</p>
</div>
<div>
<div class="progress"><div class="progress-bar progress-bar--go" style="width:100%"></div></div>
<p class="form-hint">Complete</p>
</div>
<div>
<div class="progress"><div class="progress-bar progress-bar--warn" style="width:72%"></div></div>
<p class="form-hint">Slower than expected — still running</p>
</div>
<div>
<div class="progress"><div class="progress-bar progress-bar--danger" style="width:34%"></div></div>
<p class="form-hint">Stopped at 34% — quota exceeded</p>
</div>
<div>
<div class="progress progress--indeterminate"><div class="progress-bar"></div></div>
<p class="form-hint">Working — no total known</p>
</div>
</div>
Spinner
Sized in em, so it matches whatever text or button it sits in without a second
class. .spinner-lg is the standalone size for a panel that is still loading.
A spinner says only that something is happening. It stays visible under
prefers-reduced-motion — static, but still reading as busy.
<span>Checking the connection <span class="spinner"></span></span>
<button class="btn" disabled><span class="spinner"></span> Sending…</button>
<button class="btn btn-primary" disabled><span class="spinner"></span> Saving</button>
<span class="spinner spinner-lg"></span>
Skeleton
Give each placeholder the height and width of the content that is coming, so nothing reflows when it arrives. Vary the line widths: identical bars read as a table rather than a paragraph.
Mark the container aria-busy="true". The bars themselves are decoration and
should not be announced one by one.
<div class="card" style="max-width:420px">
<div class="card-head">
<div class="skeleton skeleton-avatar"></div>
<div class="skeleton skeleton-title" style="width:40%"></div>
</div>
<div class="card-body">
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text" style="width:88%"></div>
<div class="skeleton skeleton-text" style="width:64%"></div>
</div>
</div>
A list of running work
Where these three end up in practice: one row per job, with the shape that matches what is
known. A percentage gets a determinate bar, a wait with no total gets the indeterminate one
and a .spinner, and anything finished gets a badge instead —
a bar at 100% for good says “still working”.
Add aria-busy="true" to the row while it is running, and put the figure in text
as well as in the bar. A bar's width is not a number to anybody who cannot see it.
- build 4182 — orders-api Compiling · started 2 min ago
- deploy 4181 — EU-West Waiting for the health check — no total known
- deploy 4180 — EU-Central Rolled back at 34% — quota exceeded
- deploy 4179 — UK-South Live since 08:41
<ul class="list" style="max-width:520px">
<li>
<div class="list-row">
<i class="ri-git-commit-line"></i>
<span class="list-main">
<span class="list-title">build 4182 — orders-api</span>
<span class="list-sub">Compiling · started 2 min ago</span>
<span class="progress dr-mt-1"><span class="progress-bar" style="width:64%"></span></span>
</span>
<span class="list-meta text-nums">64%</span>
</div>
</li>
<li>
<div class="list-row">
<i class="ri-rocket-line"></i>
<span class="list-main">
<span class="list-title">deploy 4181 — EU-West</span>
<span class="list-sub">Waiting for the health check — no total known</span>
<span class="progress progress--indeterminate dr-mt-1"><span class="progress-bar"></span></span>
</span>
<span class="list-meta"><span class="spinner"></span></span>
</div>
</li>
<li>
<div class="list-row">
<i class="ri-close-circle-line"></i>
<span class="list-main">
<span class="list-title">deploy 4180 — EU-Central</span>
<span class="list-sub">Rolled back at 34% — quota exceeded</span>
<span class="progress dr-mt-1"><span class="progress-bar progress-bar--danger" style="width:34%"></span></span>
</span>
<span class="list-meta"><span class="badge badge-danger">Failed</span></span>
</div>
</li>
<li>
<div class="list-row">
<i class="ri-check-double-line"></i>
<span class="list-main">
<span class="list-title">deploy 4179 — UK-South</span>
<span class="list-sub">Live since 08:41</span>
<span class="progress dr-mt-1"><span class="progress-bar progress-bar--go" style="width:100%"></span></span>
</span>
<span class="list-meta"><span class="badge badge-go">Done</span></span>
</div>
</li>
</ul>