A skeleton is the shape of content that has not arrived yet: neutral blocks holding the layout so nothing jumps when the real thing lands. is the primitive — a pulsing placeholder block — and the presets shape it to common controls by composing it.
Reach for a skeleton when the incoming content has a known shape (a list, a form, a card). It answers "something is coming, and it will look like this", which is more than a spinner says. For work with a known completion percentage, use progress instead.
Basic usage
carries the fill, the radius and the pulse. It has no size of its own — the composer sizes it, the way the content it stands in for would be sized.
<div class="skeleton-line" style="height: 1em; width: 80%;"></div>
<div class="skeleton-line" style="height: 1em; width: 100%;"></div>
<div class="skeleton-line" style="height: 1em; width: 60%;"></div>
In production, size skeletons from your own stylesheet rather than inline styles — a skeleton for a card title is part of the card's CSS, sized to the line it replaces.
Form presets
Two presets compose the primitive into form shapes: matches a field label, matches a text input's rendered box. Both go on the same element as .
<div class="skeleton-line skeleton-label"></div>
<div class="skeleton-line skeleton-input"></div>
is 44px tall because that is the input's rendered box — it tracks the control it stands in for, not the hit-area floor. A skeleton is never a pointer target, so --target-min does not apply.
Accessibility
- A skeleton is decoration: give the loading region
aria-busy="true"and let the skeleton blocks themselves stay unannounced — they contain no text, so screen readers pass over them - Do not rely on
aria-busyalone — support is inconsistent, so a user arriving mid-load can meet silence. Pair it with a visually-hidden "Loading" text node in the region (or a polite live-region announcement when loading starts and ends), removed with the skeletons - Remove
aria-busyand the hidden text when the content lands, so the region reads normally again - Under reduced motion the pulse stops and the blocks hold still — a static placeholder still says "loading". The hard requirement behind the pulse is WCAG 2.2.2 (Level A): blinking content lasting longer than five seconds needs a way to stop, which is why skeletons must resolve rather than pulse indefinitely — see the usage rules
- In forced-colors mode the fill flattens to
Canvas, so each block keeps aGrayTextborder — the loading shape survives High Contrast
Usage rules
Do:
- Match the skeleton's shape to the content that will replace it — same heights, same widths, same rhythm
- Set
aria-busy="true"on the region being loaded - Swap the skeleton for real content in place, so nothing reflows
Don't:
- Don't use a skeleton for operations with a known percentage — that is progress's job
- Don't leave skeletons pulsing indefinitely on failure — resolve to an error state that says what happened. This is the WCAG 2.2.2 mechanism, not just courtesy: blinking content that runs past five seconds needs a way to stop
- Don't stack more than a screenful of skeleton blocks; hold the visible viewport and let the rest arrive unannounced
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | Purpose |
|---|---|---|
| Placeholder fill | ||
| Placeholder corner radius | ||
| One pulse cycle |
Selectors
| Selector | Purpose |
|---|---|
| Base primitive: fill, radius, pulse animation | |
| Label preset: 14px tall, 30% wide, label margin | |
| Input preset: input-height block, full width | |
@keyframes skeleton-pulse |
The pulse: opacity 1 → 0.4 → 1 |
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. No JavaScript, nothing else to include.