First load
Who uses it: everyone, on every screen, before they can do
anything else. How often: several times an hour — it is
the most-seen state in an ERP and the least-designed.
What "done" looks like: the reader knew what was coming,
and nothing jumped when it arrived.
Not for a REFETCH of data already on screen — replacing
rows the user is reading with placeholders throws away their place and
reads as data loss; dim the existing rows with
aria-busy instead, as
list report does. And not for
waits under about a quarter of a second, where a placeholder flashing in
and out is worse than nothing at all.
Skeleton, spinner, or nothing
Three answers, and the wait decides which:
| Wait | Show | Why |
|---|---|---|
| Under ~250ms | Nothing | A placeholder that appears and vanishes reads as a flicker. The screen simply arrives. |
| ~250ms to ~10s | Skeleton | The layout is known, so show it. The reader starts reading structure before content exists. |
| Over ~10s, or unknown | Progress, with words | A skeleton implies "nearly there". Past ten seconds that is a lie — say what is happening and, if you can, how far along. |
A spinner is the fallback when the shape is genuinely unknown — a report whose columns depend on the answer. If you know the shape, a skeleton beats a spinner, because a spinner says only "wait" while a skeleton also says "this is what you will get".
Shape-matching is the whole technique
| Order | Customer | Value |
|---|---|---|
View code
<!-- Placeholders MATCH the shape of what replaces them: three
columns, the same widths, the same row height. -->
<table class="bo-data-table">
<thead>
<tr><th scope="col">Order</th><th scope="col">Customer</th>
<th scope="col" class="bo-data-table__col--numeric">Value</th></tr>
</thead>
<tbody aria-busy="true">
<tr>
<td><span class="bo-skeleton" style="inline-size: 5rem"></span></td>
<td><span class="bo-skeleton" style="inline-size: 9rem"></span></td>
<td class="bo-data-table__col--numeric"><span class="bo-skeleton" style="inline-size: 4rem"></span></td>
</tr>
<tr>
<td><span class="bo-skeleton" style="inline-size: 5rem"></span></td>
<td><span class="bo-skeleton" style="inline-size: 7rem"></span></td>
<td class="bo-data-table__col--numeric"><span class="bo-skeleton" style="inline-size: 4rem"></span></td>
</tr>
</tbody>
</table> Placeholders take the shape of what replaces them — same columns, same widths, same row height. That is not decoration: it is what stops the page moving when content lands. A generic grey block followed by a three-column table is two layouts, and the reader pays for the difference with a jump exactly when they have started reading. Vary the widths between rows so it reads as data rather than a pattern, and keep the count close to what usually arrives.
Refetch is a different state
| SO-51204 | Halden Marine AS |
View code
<!-- A REFETCH keeps the rows the user was reading and dims them.
aria-busy is the programmatic half; the dimming is the visible half. -->
<div class="bo-data-table-container" data-loading="true" tabindex="0">
<table class="bo-data-table" aria-busy="true">
<tbody><tr><td>SO-51204</td><td>Halden Marine AS</td></tr></tbody>
</table>
</div> The rows already on screen are still true until
the new ones arrive, so keep them and dim them. Swapping them for
placeholders takes away what the reader was in the middle of, which is why
the opener refuses it. aria-busy carries the programmatic
half; a table mid-swap is not "inactive", so the dimming
still has to meet contrast — this framework asserts that in
check:composited rather than assuming it.
Anatomy
- The frame that does not move — the page chrome, headings and table header render immediately from what the client already knows. Only the unknown parts get placeholders.
- Placeholders —
.bo-skeleton, sized to the content they stand in for, inside the region markedaria-busy="true". - The busy region — one element carries
aria-busy, so assistive tech is told once that a region is updating rather than once per placeholder. - The swap — placeholders are replaced by real nodes in one update. Replacing them one at a time makes the layout settle in steps, which is the jump this pattern exists to avoid.
- The failure exit — if the request fails, the placeholders must go. A skeleton that never resolves is the worst state on this page: it promises content that is not coming.
Data contract
The server owes the client two things and the client owes the reader one.
| Moment | What crosses the boundary |
|---|---|
| Request sent | The client renders the frame and the placeholders immediately — it does not wait to be told to. |
200 | The full region's markup, swapped into the busy region in one replacement. The response should include enough to remove aria-busy in the same swap. |
4xx / 5xx | An error region that REPLACES the placeholders — see error state. htmx 4 swaps non-2xx by default, so this happens with no app code; excluding a status via htmx.config.noSwap is what would leave the skeleton forever. |
| Timeout | The client's problem, not the server's: decide a ceiling and fail to the error state at it. "Still loading" after a minute is a bug that looks like patience. |
States
| State | Treatment |
|---|---|
| Loading (first paint) | Skeleton rows in shape, region aria-busy="true" |
| Loading (refetch) | Keep the rows, dim them, aria-busy — never placeholders |
| Loaded, with data | One swap, aria-busy removed in the same update |
| Loaded, empty | The empty state — and the DIFFERENT one if filters caused it |
| Failed | Error state replacing the placeholders, with a retry. The placeholders must not survive this. |
| Reduced motion | The placeholder stays, the shimmer stops — the block still reserves the space, so nothing is lost but the movement |
Components used
complexity 1 of 4 — one component and a placement rule; the judgement is all in the shape.