Skip to content
Motion & micro-interactionsLesson 6 of 8Beginner8 min

Loading & skeleton states

A skeleton that mirrors the real layout makes waiting feel shorter and prevents the layout jump a spinner can't.

Kept on this device only.

In one line: while data loads, show grey blocks shaped exactly like the content that's coming — the wait feels shorter, and nothing jumps when the real thing arrives.

What it is

A skeleton is a placeholder that traces the shape of content before that content exists. Instead of a spinner floating in empty space, you render the page's actual layout — an avatar circle, a name line, a couple of text rows — as plain grey blocks. When the data lands, each block is replaced in place by the real element it stood in for.

A faint animation usually rides on top: a shimmer, a soft highlight that sweeps left to right, or a pulse, a gentle fade in and out of opacity. The animation says "still working," the shapes say "here's what's coming."

Why it matters

A spinner answers one question — is it loading? — and tells you nothing about what you're waiting for or where it will go. A skeleton answers three: it's loading, here's roughly what arrives, and it lands there. That preview gives your eye something to settle on, which is why a skeleton-filled wait reads as shorter than a blank one of the same length, even though the clock disagrees.

The bigger win is structural. Because a skeleton occupies the exact footprint of the loaded content, the swap costs zero layout shift: text doesn't reflow, buttons don't slide out from under the cursor. A spinner that's later replaced by a tall card does the opposite — it shoves everything down the instant data arrives. Skeletons make the loading state part of the layout instead of a hole punched through it.

See it

Live demo
Tweak it2
Toggle between the skeleton and the loaded card — same layout, no shift.

How it works

Four ideas carry the whole pattern:

  1. Mirror the layout. Build the skeleton from the same boxes as the real card — same avatar diameter, same line heights, same button width. The closer the match, the more invisible the swap.
  2. Reserve the space. Give every placeholder a fixed size so the container's height is identical loading and loaded. This is what kills the jump; it's also what keeps cumulative layout shift at zero.
  3. Shimmer vs. pulse. Shimmer animates background-position to slide a highlight gradient across the block — lively, draws the eye. Pulse animates opacity — quieter, cheaper, easy to make subtle. Both run only on properties the compositor handles, so neither reflows anything.
  4. When a spinner is still fine. For a short, indeterminate wait with no known shape — a form submitting, a single icon button working — a spinner is honest and simpler. Reach for a skeleton when you know the layout that's coming and there's enough of it to preview.

Build it

Vanilla CSS skeleton + shimmer
/* Each placeholder reserves real space, so loading and loaded are the
   same height — no layout shift on swap. */
.skeleton {
  border-radius: 0.375rem;
  background-color: #e5e7eb;
}
 
.skeleton-avatar { width: 3rem; height: 3rem; border-radius: 9999px; }
.skeleton-line   { height: 0.75rem; }
.skeleton-line.is-name { width: 60%; }
.skeleton-line.is-full { width: 100%; }
.skeleton-button { width: 7rem; height: 2.25rem; }
 
/* SHIMMER: slide a soft highlight across via background-position only. */
.skeleton.shimmer {
  background-image: linear-gradient(
    90deg,
    transparent 0%,
    rgba(255, 255, 255, 0.6) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  background-repeat: no-repeat;
  animation: skeleton-sweep 1.4s ease-in-out infinite;
}
 
@keyframes skeleton-sweep {
  0%   { background-position: 150% 0; }
  100% { background-position: -150% 0; }
}
 
/* PULSE: a softer alternative — opacity only. */
.skeleton.pulse {
  animation: skeleton-fade 1.4s ease-in-out infinite;
}
 
@keyframes skeleton-fade {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.5; }
}
 
/* a11y: stop the motion; the static grey blocks still convey "loading". */
@media (prefers-reduced-motion: reduce) {
  .skeleton.shimmer,
  .skeleton.pulse {
    animation: none;
    background-image: none;
  }
}

Make it yours

Use the controls beside the demo above to change loading and idle style — each change updates the example live.

Experiment in the playground

Reproduce it with an LLM

Reproduce it with an LLM

You are a senior front-end engineer. Build a loading state for a profile card (avatar, name, two lines of text) as a skeleton that mirrors the card's real layout: grey placeholder blocks in the exact positions and sizes of the eventual content, with a gentle left-to-right shimmer. Build it in React. Requirements: the skeleton must reserve the same space as the loaded content so there is no layout shift when it swaps in; expose the loading state via aria-busy on the container and hide the decorative skeleton from screen readers; the shimmer must stop (show a static skeleton) under prefers-reduced-motion: reduce; animate only background-position or transform. Return only the component code with imports and CSS.

Pitfalls & accessibility

Further reading

  • Cumulative Layout Shift in the Core Web Vitals, for why reserving space during load is a measured quality signal, not just a nicety.
  • Your animation library's notes on which CSS properties stay on the compositor — the list that decides whether a shimmer is cheap or costly.