busy-office-ui
Menu

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:

Which loading treatment to use
WaitShowWhy
Under ~250msNothingA placeholder that appears and vanishes reads as a flicker. The screen simply arrives.
~250ms to ~10sSkeletonThe layout is known, so show it. The reader starts reading structure before content exists.
Over ~10s, or unknownProgress, with wordsA 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

OrderCustomer 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-51204Halden 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

  1. 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.
  2. Placeholders — .bo-skeleton, sized to the content they stand in for, inside the region marked aria-busy="true".
  3. The busy region — one element carries aria-busy, so assistive tech is told once that a region is updating rather than once per placeholder.
  4. 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.
  5. 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.

First-load request and response contract
MomentWhat crosses the boundary
Request sentThe client renders the frame and the placeholders immediately — it does not wait to be told to.
200The 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 / 5xxAn 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.
TimeoutThe 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

First-load states
StateTreatment
Loading (first paint)Skeleton rows in shape, region aria-busy="true"
Loading (refetch)Keep the rows, dim them, aria-busy — never placeholders
Loaded, with dataOne swap, aria-busy removed in the same update
Loaded, emptyThe empty state — and the DIFFERENT one if filters caused it
FailedError state replacing the placeholders, with a retry. The placeholders must not survive this.
Reduced motionThe 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.

↑↓ move Enter open Esc close