Skeleton
Summary
Shimmering placeholder shapes that hold layout while content
loads — the first paint of async views. Pure CSS, no JS: compose
-text, -heading, -circle
and -block shapes to mirror the eventual layout, so
nothing jumps when real content arrives. The shimmer goes static
under prefers-reduced-motion; the shapes still read
as placeholders.
When to use
| Component | Use it for |
|---|---|
| Skeleton | First paint of a view whose layout you already know: cards, lists, detail panes. Mirror the shapes, avoid layout shift. |
| Spinner | Short unknown waits with no layout to reserve: a refreshing panel, a pending button action. |
| Progress | Work with a known fraction (or an indeterminate task the user is actively tracking). |
Examples
HTML
A paragraph placeholder — one heading line, then text lines with the last one shortened so the block reads as prose:
<div aria-hidden="true">
<div class="soma-skeleton soma-skeleton-heading"></div>
<div class="soma-skeleton soma-skeleton-text"></div>
<div class="soma-skeleton soma-skeleton-text"></div>
<div class="soma-skeleton soma-skeleton-text" style="width: 60%"></div>
</div>
Circles for avatars. Size them inline to the avatar scale being held (32px is the default):
<span class="soma-skeleton soma-skeleton-circle"></span>
<span class="soma-skeleton soma-skeleton-circle" style="width: 48px; height: 48px"></span>
Blocks for charts, images and tables. Set the height to the region being held:
<div class="soma-skeleton soma-skeleton-block" style="height: 96px"></div>
A composed loading card — the same shapes, in the same places, as the content that will replace them:
<div aria-hidden="true">
<span class="soma-skeleton soma-skeleton-circle"></span>
<div class="soma-skeleton soma-skeleton-heading" style="width: 40%"></div>
<div class="soma-skeleton soma-skeleton-text"></div>
<div class="soma-skeleton soma-skeleton-text" style="width: 75%"></div>
<div class="soma-skeleton soma-skeleton-block" style="height: 96px"></div>
</div>
Skeletons are purely visual: mark the placeholder region
aria-hidden="true" and announce the loading state
separately (a spinner with
role="status", or aria-busy on the
region being filled). Sibling skeletons space themselves
automatically (8px between adjacent shapes), so a stack of lines
needs no layout wrapper.
CSS classes
| Class | Effect |
|---|---|
.soma-skeleton | The shimmer base, always combined with a shape class; size inline where the default doesn't fit. Adjacent skeletons get 8px spacing automatically. Under reduced motion the shimmer stops: a flat neutral fill. |
.soma-skeleton-text | One body-text line (14px tall); set width inline to vary line lengths. |
.soma-skeleton-heading | A taller (20px) line for headings and titles; 40% wide by default. |
.soma-skeleton-block | A rectangle for charts, images, tables; 80px tall by default, set height inline. |
.soma-skeleton-circle | A 32px disc for avatars and icon slots; resize with inline width/height. |
Composing loading cards
The one rule: mirror the eventual layout. Build the skeleton
with the same wrappers, grid columns and gaps as the
real card, and swap only the leaf content for shapes — then the
swap is invisible except for the content itself. If the real
card is an avatar + heading + two lines, the skeleton is a
circle + heading line + two text lines in the same flex row.
Sizing that can't come from the shape defaults goes inline
(width for line lengths, height for
blocks), which keeps the placeholder honest about the space the
content will take.
Repeat a row skeleton for lists, but cap it at roughly one viewport of rows: a skeleton is a promise of layout, not a progress indicator, and a thousand shimmering rows help nobody.
JavaScript
None as a component API: skeletons are CSS-only, and the lifecycle is the consumer's render logic. The swap pattern: mark the region busy, show the placeholder, and replace it wholesale when the data arrives:
const region = document.getElementById('profile-card');
async function load() {
region.setAttribute('aria-busy', 'true'); // announces "loading" for the region
region.innerHTML = skeletonCardHTML; // the aria-hidden placeholder markup
const user = await fetchUser();
region.innerHTML = renderCard(user); // same layout, real content
region.removeAttribute('aria-busy');
}
Swap when the data is ready — not shape by shape. Partial swaps (title in, body still shimmering) read as broken rather than fast. If one region is genuinely slower (a chart engine), give it its own skeleton block and its own swap, inside an already-real card.
When the wait can end empty, the skeleton's replacement is an empty state, not a blank region; decide that branch in the same render function.
The live swap example above, exactly as this page wires it:
const region = document.getElementById('skel-region');
const cardHTML = region.innerHTML; // the real card, captured
document.getElementById('skel-reload').addEventListener('click', async () => {
region.setAttribute('aria-busy', 'true');
region.innerHTML = `
<div class="demo-media" aria-hidden="true">
<span class="soma-skeleton soma-skeleton-circle"></span>
<div class="demo-media-body">
<div class="soma-skeleton soma-skeleton-heading" style="width: 40%"></div>
<div class="soma-skeleton soma-skeleton-text"></div>
</div>
</div>`;
await new Promise((resolve) => setTimeout(resolve, 1500)); // fake fetch
region.innerHTML = cardHTML;
region.removeAttribute('aria-busy');
});