v1.2.0
  1. ds
  2. Components
  3. Spinner and progress

Spinner and progress

Loading states that say how long, when they can.

Spinner

Loading tables…
Code
<span class="spin sm" role="status" aria-label="Loading"></span>
<span class="spin" role="status" aria-label="Loading"></span>
<span class="spin lg" role="status" aria-label="Loading"></span>
<span class="spin xl" role="status" aria-label="Loading"></span>
<span class="spin muted" role="status" aria-label="Loading"></span>
<span class="loading"><span class="spin sm muted"></span>Loading tables…</span>

A ring with one bright quarter, in the current colour, turning at 0.7 seconds. Use it when you cannot say how long something will take. Under reduced motion it slows down rather than stopping, because a frozen spinner looks like a hang.

In buttons

Code
<button class="btn primary" aria-busy="true"><span class="spin sm"></span>Deploying</button>
<button class="btn" aria-busy="true"><span class="spin sm"></span>Testing connection</button>
<button class="btn sm ghost" aria-busy="true"><span class="spin sm"></span>Refreshing</button>

Swap the icon for a spinner and the verb for its -ing form, and set aria-busy. The button keeps its width close to what it was, and people can see which action is running.

Progress

Backfilling orders, 2024-01-01 to 2026-10-1262%
412 of 665 partitions · about 9 minutes left
Rebuilding search index…
Counting documents
Exporting events_raw18%
Stopped at partition 2025-03-14: disk quota exceeded on /scratch
Show code
<div class="progress">
  <b>Backfilling orders, 2024-01-01 to 2026-10-12</b><output>62%</output>
  <div class="meter" role="progressbar" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-label="Backfill" style="--v:.62"><i></i></div>
  <small>412 of 665 partitions · about 9 minutes left</small>
</div>
<div class="progress">
  <b>Rebuilding search index</b><output>…</output>
  <div class="meter indeterminate" role="progressbar" aria-label="Rebuilding search index"><i></i></div>
  <small>Counting documents</small>
</div>
<div class="progress">
  <b>Exporting events_raw</b><output>18%</output>
  <div class="meter down" role="progressbar" aria-valuenow="18" aria-valuemin="0" aria-valuemax="100" aria-label="Export" style="--v:.18"><i></i></div>
  <small class="err">Stopped at partition 2025-03-14: disk quota exceeded on /scratch</small>
</div>

When you can count, show the count. A label, a figure, the meter and one line of what is left in units people know: partitions, rows, files, minutes. Start indeterminate while counting, then switch to the real number.

Uploads

  • orders_2026_09.parquet48 / 112 MB
  • customers.csv2.1 MBUploaded, 18,204 rows
  • refunds.xlsx0.4 MBSpreadsheets are not supported. Export it as CSV.
Code
<ul class="uploads">
  <li class="upload"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg><div class="progress"><b>orders_2026_09.parquet</b><span class="v">48 / 112 MB</span><div class="meter" style="--v:.43"><i></i></div></div><button class="btn icon xs ghost" aria-label="Cancel upload"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></li>
  <li class="upload ok"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg><div class="progress"><b>customers.csv</b><span class="v">2.1 MB</span><small>Uploaded, 18,204 rows</small></div><button class="btn icon xs ghost" aria-label="Remove"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button></li>
  <li class="upload err"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg><div class="progress"><b>refunds.xlsx</b><span class="v">0.4 MB</span><small class="err">Spreadsheets are not supported. Export it as CSV.</small></div><button class="btn xs" >Retry</button></li>
</ul>

API

ClassOnWhat it does
.spinspanThe spinner, 1rem, in the accent. Give it role="status" and a label when it stands alone.
.sm / .lg / .xl.spin0.75, 1.5 and 2.25rem.
.muted.spinGrey, for secondary loading.
aria-busy="true".btnA button doing its job. Put a .spin inside.
.loadingspanA spinner and a line of text. .block centres it in a panel.
.progressdivLabel in <b>, figure in <output>, a .meter, a <small> line.
.uploads / .uploadul / liOne row per file: icon, .progress, an action. .ok and .err.
Do
  • A skeleton for content with a known shape, a spinner for the rest.
  • Time left when it is more than a few seconds.
  • Say what is loading.
Don't
  • A spinner for under 300 ms. It only flickers.
  • Full-page spinners while part of the page could show.
  • Percentages that jump back.

Accessibility

  • A spinner on its own needs role="status" and a label. Inside a busy button, the button's text says it.
  • Give progress bars role="progressbar" with aria-valuenow, and leave out the value while indeterminate.