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

ComponentUse it for
SkeletonFirst paint of a view whose layout you already know: cards, lists, detail panes. Mirror the shapes, avoid layout shift.
SpinnerShort unknown waits with no layout to reserve: a refreshing panel, a pending button action.
ProgressWork with a known fraction (or an indeterminate task the user is actively tracking).

Examples

Text lines

-heading + -text:

A taller heading line, then body lines; vary the last line's width inline so the block reads as a paragraph.

Circle

Avatars and icon slots:

32px by default; resize with inline width/height to match the avatar scale being held.

Block

Charts, images, tables:

80px by default; set height inline to match the region being held.

Loading card

Compose shapes to mirror the real layout:

Avatar + title + two text lines + a chart block — the same shapes, in the same places, as the card that will replace them. Nothing jumps on arrival.

Swap to content

Skeleton in, real card out (the full lifecycle):

JN

Julia Nash

Maintainer · last active 5 min ago — currently reviewing NW-214.

Click reloads: the region swaps to the matching skeleton (marked aria-hidden, region set aria-busy), then the card returns. The shapes match the card, so nothing moves.

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

ClassEffect
.soma-skeletonThe 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-textOne body-text line (14px tall); set width inline to vary line lengths.
.soma-skeleton-headingA taller (20px) line for headings and titles; 40% wide by default.
.soma-skeleton-blockA rectangle for charts, images, tables; 80px tall by default, set height inline.
.soma-skeleton-circleA 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');
});