Skeleton

A grey block where content will be, shimmering to say it is on its way. It works because it holds the shape of the thing that is loading, so the page does not jump when the real content replaces it — which is the entire justification for the pattern.

When to use it

When you know the shape of what is coming and it will take long enough to notice — roughly a third of a second and up. A list of rows, a card, an avatar and a name.

When you do not know the shape, a skeleton is a lie about the layout, and the page will still jump. Use a spinner and accept the honest answer.

The shape of a card that has not loaded — note aria-hidden on the whole block
<div class="card" style="max-inline-size:24rem" aria-hidden="true"> <div class="cluster"> <span class="skeleton skeleton-circle" style="inline-size:40px;block-size:40px"></span> <span class="stack stack-1" style="flex:1"> <span class="skeleton skeleton-text" style="inline-size:40%"></span> <span class="skeleton skeleton-text" style="inline-size:60%"></span> </span> </div> <p class="skeleton skeleton-text"></p> <p class="skeleton skeleton-text" style="inline-size:80%"></p> </div>
<div class="card" style="max-inline-size:24rem" aria-hidden="true">
  <div class="cluster">
    <span class="skeleton skeleton-circle" style="inline-size:40px;block-size:40px"></span>
    <span class="stack stack-1" style="flex:1">
      <span class="skeleton skeleton-text" style="inline-size:40%"></span>
      <span class="skeleton skeleton-text" style="inline-size:60%"></span>
    </span>
  </div>
  <p class="skeleton skeleton-text"></p>
  <p class="skeleton skeleton-text" style="inline-size:80%"></p>
</div>

Sizing it

.skeleton is block-size: 1em and full width by default, so it takes the size of the text it stands in for. .skeleton-text shortens it to .8em and adds vertical margins so a stack of lines has the rhythm of a paragraph. .skeleton-circle is the same block with a full radius, for an avatar.

Width is yours. Varying it line by line is what stops the block reading as a table, and a last line at 60–80% is what makes it read as a paragraph.

A heading, a paragraph and an image well
<div class="stack stack-3" style="max-inline-size:26rem" aria-hidden="true"> <span class="skeleton" style="block-size:1.6em;inline-size:55%"></span> <div class="stack stack-1"> <span class="skeleton skeleton-text"></span> <span class="skeleton skeleton-text"></span> <span class="skeleton skeleton-text" style="inline-size:72%"></span> </div> <span class="skeleton" style="block-size:8rem"></span> </div>
<div class="stack stack-3" style="max-inline-size:26rem" aria-hidden="true">
  <span class="skeleton" style="block-size:1.6em;inline-size:55%"></span>
  <div class="stack stack-1">
    <span class="skeleton skeleton-text"></span>
    <span class="skeleton skeleton-text"></span>
    <span class="skeleton skeleton-text" style="inline-size:72%"></span>
  </div>
  <span class="skeleton" style="block-size:8rem"></span>
</div>

.skeleton also sets color: transparent, which is there so you can wrap real placeholder text and get its natural width without seeing it. That is useful and it is a trap — see Accessibility, because transparent text is still read aloud.

Classes

Generated from src/07-components.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.skeleton deck.components Sets background, background-size, animation, border-radius, and 3 more. 07-components.css:570
.skeleton-circle deck.components Sets border-radius. 07-components.css:581
.skeleton-text deck.components Sets block-size, margin-block. 07-components.css:580

Tokens

Design tokens this component reads
Token Live Declared value
--ink-100 oklch(95.4% calc(var(--chroma-neutral) * 1.4) var(--hue-neutral))
--ink-200 oklch(91.2% calc(var(--chroma-neutral) * 1.8) var(--hue-neutral))
--r-xs 4px
--r-full 999px
--ease-in-out cubic-bezier(.65, 0, .35, 1)

Accessibility

  • A skeleton announces nothing. It is an empty element with a background. A screen reader user gets silence where a sighted user gets a clear "this is loading" signal. Everything below follows from that.
  • Hide the skeleton and announce the state. aria-hidden="true" on the placeholder block, and aria-busy="true" on the region that is loading. Where the wait is long, add a live region that says "Loading results" and, when it finishes, how many arrived.
  • Transparent text is still announced. color: transparent hides text visually and does nothing to the accessibility tree. Wrap real words in a .skeleton without hiding it and a screen reader reads out your dummy copy as though it were the content. This is the most likely way to get this component wrong.
  • Do not let the skeleton be focusable. If you are replacing a list of links, the placeholders must not be links. An aria-hidden element containing a tab stop is a trap — the focus lands somewhere a screen reader cannot describe.
  • Move focus deliberately when content arrives. If the reader was focused inside the region that has just been replaced, focus falls back to the body silently. Decide where it should go before the swap.
  • It is animation in the periphery. A page of shimmering blocks is a lot of movement for anyone with a vestibular disorder or an attention difficulty. Deck slows it rather than stopping it — see below — and if the wait is long, fewer skeletons is kinder than more.

Right to left

Nothing here is directional in a way that needs a rule, and nothing in src/19-logical.css touches it. Widths are set with inline-size, so a partial line shortens from the correct end on its own.

The shimmer itself sweeps the same way in both directions — the gradient is at a fixed 100deg and the animation moves the background position, neither of which is mirrored. It is a texture rather than a direction of travel, so this is defensible; it is worth knowing rather than assuming it flips.

The short line ends on the correct side
<div dir="rtl" class="stack stack-1" style="max-inline-size:22rem" aria-hidden="true"> <span class="skeleton skeleton-text"></span> <span class="skeleton skeleton-text" style="inline-size:65%"></span> </div>
<div dir="rtl" class="stack stack-1" style="max-inline-size:22rem" aria-hidden="true">
  <span class="skeleton skeleton-text"></span>
  <span class="skeleton skeleton-text" style="inline-size:65%"></span>
</div>

Reduced motion

.skeleton is one of seven things src/02-reset.css exempts from the reduced-motion freeze. Instead of stopping, the shimmer slows from 1.4 seconds to 2.4 and keeps running:

.spinner, .icon-spin, .skeleton, .marquee-track, .btn.is-loading::after,
.dg-wrap.is-loading::after, .ping::after {
  animation-duration: 2.4s !important;
  animation-iteration-count: infinite !important;
}

The reasoning in the source is that loading feedback is information rather than decoration, and a frozen skeleton is indistinguishable from a page that has given up. That is a real trade-off rather than an oversight: a reader who asked for no motion still gets some. If your application would rather respect the request completely, override it — but replace the shimmer with something static and obviously "waiting", not with nothing.

Printing

.skeleton is not in src/99-print.css and is not in the list of elements given print-color-adjust: exact, so its background is dropped and it prints as blank space the size of the content that never arrived.

Which is roughly the right outcome, since a printed page of skeletons has no information on it whatever it looks like. If a page is likely to be printed mid-load, print a line of text saying so rather than leaving a gap the reader has to interpret.

Overriding it

/* One block, no layer needed */
<span class="skeleton" style="block-size:3rem;inline-size:70%"></span>

@layer app.components {
  /* A slower, flatter shimmer */
  .skeleton { animation-duration: 2.2s; }

  /* No shimmer at all — a plain tinted block */
  .skeleton { animation: none; background: var(--ink-100); }
}

Note that removing the animation in your own layer does not remove the reduced-motion rule, which carries !important. It has nothing to act on once animation is none, so the result is what you asked for.

When not to use it

  • Not when you do not know the shape. A skeleton that does not match what arrives causes the layout shift it was supposed to prevent, and costs a shimmer on the way.
  • Not for a wait under about 300ms. A flash of placeholder and then content is worse than a brief nothing. Delay showing the skeleton, or do not show one.
  • Not for an indeterminate wait. Skeletons imply "nearly there". For a job that may take a minute, use a progress bar and say what is happening.
  • Not for a whole page. A full screen of shimmer is a lot of motion and no information. Show the chrome that is already known — the header, the nav — and put skeletons only where data is genuinely pending.
  • Not as the only loading signal. It is invisible to assistive technology. aria-busy and a live region are not optional extras here.
  • Not around real text without hiding it. See Accessibility — transparent is not hidden.