Skeleton loaders in pure CSS

Skeleton loaders in pure CSS Add to Google

A spinner tells the user one thing: wait. It does not say how long, or what is coming, and when the data lands the layout jumps as real content pushes everything down. Then the request takes 80ms on your machine and the spinner flashes for a split second, which somehow looks worse than nothing.

The alternative is a skeleton: gray blocks shaped like the content that is coming. It is one gradient and one keyframe, and no component library is involved.

The fix

css
.skeleton {
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 37%, #eee 63%);
  background-size: 300% 100%;
  animation: shimmer 1.4s ease-in-out infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  from { background-position: 100% 0; }
  to   { background-position: 0 0; }
}
html
<p class="skeleton" style="height: 1rem; width: 60%"></p>

That is the whole mechanic. A wide gradient sliding across a fixed box, which reads as a sheen moving over the placeholder.

The slow load takes 1.8 seconds, the fast one 100ms. The fast one never shows a skeleton, because the placeholders wait 300ms before they appear. Nothing in the script knows about that delay.

The size is the point

A skeleton that is not the shape of the content it replaces is a fancier spinner. The reason to build one is that the page does not move when the data arrives, so the block has to match:

css
.skeleton-title  { height: 1.25rem; width: 45%; }
.skeleton-line   { height: 0.75rem; }
.skeleton-line:last-child { width: 65%; }
.skeleton-avatar { height: 2.5rem; width: 2.5rem; border-radius: 50%; }

The short last line is a small thing that does most of the work. Paragraphs do not end flush with the margin, and a stack of equal-width bars reads as a chart, not text.

Images and media get their box from aspect-ratio rather than a hard height, the same way the real thing should.

Do not show it for a fast response

Most requests come back quickly. Flashing a skeleton for 60ms is a flicker, and users read flicker as breakage. Delay the appearance so quick responses never show one:

css
.skeleton {
  animation: shimmer 1.4s ease-in-out infinite,
             appear 0s 0.3s forwards;
  opacity: 0;
}

@keyframes appear { to { opacity: 1; } }

A zero-duration animation with a delay is just a scheduled state change, in CSS, with no timer to clear if the data arrives first. If the response takes 100ms, the skeleton was never visible.

The mirror image of this rule: once it is showing, keep it up for a beat. A skeleton that appears at 300ms and vanishes at 310ms is the flicker you were avoiding, from the other direction. That part lives in your loading code, not the CSS.

What screen readers should get

Nothing, until there is something to read. Gray boxes have no content, so announcing them is noise:

html
<div class="card" aria-busy="true">
  <p class="skeleton skeleton-title" aria-hidden="true"></p>
  <p class="skeleton skeleton-line" aria-hidden="true"></p>
</div>

aria-busy on the container says this region is still loading, and it comes off when the real content lands. aria-hidden on the bars keeps the placeholders out of the accessibility tree. Do not add aria-live to the container just to be safe: the moment the real content replaces the skeleton, the whole block gets read out over whatever the user was doing.

Reduced motion

css
@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: appear 0s 0.3s forwards; background: #eee; }
}

A loop that runs across the whole screen for as long as the network takes is exactly the kind of movement people turn off. The static gray block still does the job. Keep the appear animation, though: it is not motion, and without it the skeleton stays at opacity: 0 forever.

Where this comes up

  • Dashboards: where six widgets each wait on their own request.
  • Feeds and lists: three or four skeleton rows, not twenty. Enough to fill the fold.
  • Profile and detail pages: avatar, name, two lines of bio, all in known positions.
  • Tables: keep the header real and skeleton only the body rows, so column widths never shift.

Why this matters

  • It removes the layout jump: the placeholder already occupies the space the content will take.
  • It reads as progress: a shape that resembles the coming content tells the user the right thing is loading, not that something is stuck.
  • Zero dependencies: one class, a few sizes, and a media query.

Open the slowest screen in your app and reload it with throttling on. If the page rearranges itself when data lands, the fix is a handful of gray boxes.

Happy coding!

Marko

Rate this issue

Your ratings shape which issues I recommend to you.