Loading, empty & error states
Three states every data view needs: loading (.bo-skeleton),
nothing to show (.bo-state), and something went wrong
(.bo-state--error) — one component, two settings, not two
separate components. The icon shape differs, not just its color,
and the title always says which state it is.
Not for a failure inside an otherwise-working screen —
a full-view error state replaces everything, so using it when one
fragment failed throws away all the content that DID load. A widget
that could not refresh or a save that was rejected wants an inline
alert beside the thing it is
about, with the rest of the screen left standing; these states are for
when there is genuinely nothing else to show.
Skeleton — text lines
Pair with aria-busy="true" on the loading region — the
shimmer is the visual channel, aria-busy is the
programmatic one. Widths are set inline per line so the placeholder
roughly matches the real content's ragged edge.
View code
<div class="bo-stack bo-stack--tight" role="status" aria-busy="true" aria-label="Loading purchase orders">
<span class="bo-skeleton" style="inline-size: 40%"></span>
<span class="bo-skeleton" style="inline-size: 90%"></span>
<span class="bo-skeleton" style="inline-size: 75%"></span>
</div> --circle / --block
An avatar + two-line card placeholder.
View code
<div class="bo-cluster" style="--bo-cluster-gap: var(--bo-space-3); align-items: center" role="status" aria-busy="true" aria-label="Loading vendor">
<span class="bo-skeleton bo-skeleton--circle"></span>
<div class="bo-stack bo-stack--tight" style="flex: 1">
<span class="bo-skeleton" style="inline-size: 50%"></span>
<span class="bo-skeleton bo-skeleton--block"></span>
</div>
</div> Quick reference generated
| Class | Role |
|---|---|
| .bo-skeleton | block (root) |
| .bo-skeleton--block | variant: block |
| .bo-skeleton--circle | variant: circle |
API generated from CSS
| Blocks | bo-skeleton |
|---|---|
| Variants | bo-skeleton--blockbo-skeleton--circle |
| JS required | None — CSS-only. |
- The shimmer is a continuous loop, not a one-shot transition, so it is NOT driven by the --bo-motion-duration-* tokens — it gets an explicit prefers-reduced-motion override instead (same reasoning as .bo-motion-spin in the Motion module).
Contrast — 9 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-muted | 6.87 | 5.66 |
| border-control | bg-muted | 4.39 | 5.66 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (15/15) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | base block is 1em so it matches the line of text it stands in for; no raw font-size |
| Colour | 3 / 3 | gradient sweeps --bo-color-bg-muted to --bo-color-skeleton-highlight and back, zero raw colour; the highlight is its own token precisely so it cannot be byte-identical to the base in either theme. forced-colors adds a 1px GrayText border so the placeholder does not vanish when backgrounds are stripped |
| Spacing | 3 / 3 | the 2.5rem avatar and 4rem block placeholders both state why they are absolute rather than em (94.6b) |
| Interaction | n/a | a static placeholder; it has no interaction surface |
| Content | 3 / 3 | page opener (state-patterns) carries the wrong-choice clause: "Not for a failure inside an otherwise-working screen" — a full-view state replaces everything, so one failed fragment wants an inline alert with the rest of the screen left standing. Blind re-scored 2026-08-23 (Polish round 14); the table first-load caption's data-loading pointer verified to reinforce, not contradict. |
| Fit | 3 / 3 | loading state for a region whose shape is known; distinct from .bo-state’s empty/error |
Table first-load — skeleton rows
| Invoice # | Vendor | Amount |
|---|---|---|
The #1 ERP loading case: real header (columns are
known), skeleton rows sized like the data they stand in for,
aria-busy on the <tbody>. For a mid-session
re-fetch of a table that already has rows, use
data-loading instead — keep
the stale rows visible and dimmed.
<tbody aria-busy="true">
<tr>
<td><span class="bo-skeleton" style="inline-size: 6rem"></span></td>
<td><span class="bo-skeleton" style="inline-size: 10rem"></span></td>
<td class="bo-data-table__col--numeric"><span class="bo-skeleton" style="inline-size: 5rem"></span></td>
</tr>
…
</tbody> The motion, and why it is timed this way
One sweep, left to right, every 1.5 seconds. Published guidance puts the comfortable band at 1.5–2s: under a second reads as glitchy and anxious, over two as broken. Slow it 4× above to see the band's path clearly — the controls are here because a loop that never stops is easy to look straight past, which is exactly how this animation shipped broken.
It used to run four times too far.
The gradient is twice the width of its box, and the old keyframes moved it
from 200% to −200% — four box-widths per cycle, so the highlight
crossed roughly twice every 1.5s. The duration matched the guidance while
the travel did not, which is what made it feel fast. Percentage
background-position aligns the P% point of the image with the
P% point of the box, so at 200% the band starts a full box
off-screen; 100% → 0% is exactly one pass.
Easing matters too: ease-in-out accelerates
the band through the middle, which reads as hurried even at the right
duration. A travelling highlight wants linear.
Reduced motion stops it and keeps the
block. The placeholder still occupies its space and still pairs
with aria-busy, so nothing is lost — only the movement. That
also answers the WCAG Pause, Stop, Hide concern for animation
that outlasts five seconds: the user's own OS preference is the control.
Empty
No purchase orders yet
Orders you create will show up here.
View code
<div class="bo-state">
<span class="bo-state__icon" aria-hidden="true">📭</span>
<p class="bo-state__title">No purchase orders yet</p>
<p class="bo-state__description">Orders you create will show up here.</p>
<div class="bo-state__actions">
<button class="bo-btn">Create purchase order</button>
</div>
</div> Empty — "no results match your filters"
No POs match these filters
Status "Rejected" + CC-4021 has no results in this period.
View code
<div class="bo-state">
<span class="bo-state__icon" aria-hidden="true">🔍</span>
<p class="bo-state__title">No POs match these filters</p>
<p class="bo-state__description">Status "Rejected" + CC-4021 has no results in this period.</p>
<div class="bo-state__actions">
<button class="bo-btn bo-btn--secondary" type="button">Clear filters</button>
</div>
</div> Distinct from first-run empty: name the filters that
caused it and offer the one-click way out — otherwise users assume data loss.
It is the same component with different words — there is no
--empty modifier and none is needed. A first-run empty says
"nothing exists yet" and offers create; a filtered empty says
"nothing matches" and offers clear. Getting that wrong is what
makes users think their data is gone.
--error
Couldn't load purchase orders
Something went wrong on our end. Try again, or check back shortly.
View code
<div class="bo-state bo-state--error">
<span class="bo-state__icon" aria-hidden="true">⚠</span>
<p class="bo-state__title">Couldn't load purchase orders</p>
<p class="bo-state__description">Something went wrong on our end. Try again, or check back shortly.</p>
<div class="bo-state__actions">
<button class="bo-btn bo-btn--secondary">Retry</button>
</div>
</div> Quick reference generated
| Class | Role |
|---|---|
| .bo-state | block (root) |
| .bo-state--error | variant: error |
| .bo-state__actions | part: actions |
| .bo-state__description | part: description |
| .bo-state__icon | part: icon |
| .bo-state__title | part: title |
API generated from CSS
| Blocks | bo-state |
|---|---|
| Parts | bo-state__actionsbo-state__descriptionbo-state__iconbo-state__title |
| Variants | bo-state--error |
| JS required | None — CSS-only. |
- A .bo-state is decoration around a message — the title/description TEXT is the accessible signal; the icon carries aria-hidden="true" so it is never announced as unlabeled content.
Contrast — 17 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | danger-subtle | 5.91 | 8.06 |
| danger-text | bg-surface | 6.47 | 8.89 |
| danger-text | bg-canvas | 6.19 | 9.96 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| text-primary | bg-muted | 16.12 | 13.75 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (15/15) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | title from --bo-font-size-lg; the 2rem icon now states why it is absolute — a glyph acting as art, not running text (94.6b) |
| Colour | 3 / 3 | zero raw colour, and two-channel by construction: the icon SHAPE differs per state and the text always names it |
| Spacing | 3 / 3 | gap/padding from space tokens; the 32ch measure carries its reason (94.6b) |
| Interaction | n/a | a layout for an empty/error state; the actions inside it are .bo-btn |
| Content | 3 / 3 | page opener (state-patterns) carries the wrong-choice clause: "Not for a failure inside an otherwise-working screen" — same clause as skeleton's, one page, two scored components. Blind re-scored 2026-08-23 (Polish round 14). |
| Fit | 3 / 3 | the empty and error states every list and detail screen needs; distinct from .bo-skeleton’s loading |