Loading
A spinner for an action someone triggered and is waiting on, like the save button; a skeleton for content that is still on its way and already has a known shape, like a table row. The skeleton prevents the jump: the space is already reserved at the size of what is coming. Classes only, no JavaScript.
Spinner
Click save: the button stays busy for two seconds.
Skeleton
The shape of what is coming, in the place where it will land.
Examples
The spinner is sized in em and painted with currentColor, so it follows wherever it sits without any variant.
Busy button
aria-busy="true" with disabled. Inside the button, the spinner takes the place and the size of the icon.
Inline with text
At the height of the letters, and in their color.
Fetching invoices…
Table row
The skeleton can be any element, including a <td>.
| Customer | Amount |
|---|---|
Avatar and card
is-circle turns round at whatever width you give it.
How to use
<button class="tuc-btn is-primary" aria-busy="true" disabled>
<span class="tuc-spinner"></span> Saving
</button>
<span class="tuc-spinner is-lg"></span>
<div class="tuc-skeleton" style="width:60%"></div>
<span class="tuc-skeleton is-circle" style="width:36px"></span>The skeleton is a block 1em tall and full width: the width and, when needed, the height are up to you.
Don't hand-roll a spinner with your own SVG or border — the loading toast and the components use this one.
Busy is not disabled
disabled prevents the second click, but the 50% fade says "not available", and the button is working. With aria-busy="true" it keeps its full color and gets the wait cursor.
With HTMX
htmx-indicator is HTMX's own class: hidden, and visible only during the request. Paired with the
spinner, it shows up and goes away without a single line of JavaScript.
<button class="tuc-btn is-primary" hx-post="{% url 'contract-save' contract.pk %}" hx-disabled-elt="this">
<span class="tuc-spinner htmx-indicator"></span> Save
</button>The skeleton works as placeholder content for what HTMX fetches as soon as the page opens. When the response arrives, it replaces the skeleton in the same space, and nothing jumps.
<div hx-get="{% url 'customers-table' %}" hx-trigger="load" hx-swap="outerHTML">
<span class="tuc-skeleton" style="width:60%"></span>
<span class="tuc-skeleton" style="width:80%; margin-top:8px"></span>
</div>Accessibility
The text says what is happening, and the spinner confirms it. "Saving" next to the spinner gets announced; a spinner on its own is just a spinning circle to someone who can't see it.
The skeleton is decoration: it doesn't select text, doesn't take the pointer, and its text, if any, is transparent. With
prefers-reduced-motion the skeleton's shimmer stops entirely, because it is only ornament. The spinner keeps
spinning, more slowly: standing still, it would stop saying "waiting" and become just another circle.
Classes
| Class or attribute | What for |
|---|---|
.tuc-spinner | Spinning circle, 1em, in the text color |
is-lg | On the spinner: 1.5em |
.tuc-btn > .tuc-spinner | Inside the button, at the size of its icon |
aria-busy="true" | On .tuc-btn: busy, with full color and the wait cursor, even with disabled |
.tuc-skeleton | Shimmering block, in place of the content that is coming |
is-circle | On the skeleton: round, with height equal to width |
htmx-indicator | From HTMX: shows the element only during the request |