Skeleton loading screens: shimmer done right

Placeholders that match the real layout, a shimmer the compositor can run on its own, a calm fallback for reduced motion, and markup that tells screen readers what is happening.

CSS recipe · By AurumCodeWorks · 7 min read · Updated 12 October 2026

Card, transform shimmer — a gradient on ::after slides with translateX(); no repaint per frame.
Avatar rows, background-position — the classic gradient sweep; simple, but every block repaints each frame.
Opacity pulse — no movement across the screen; what the other two become under reduced motion.

When a skeleton beats a spinner

A skeleton screen is a grey sketch of the layout that is about to appear: a block where the image will go, bars where the headline and text will sit. Unlike a spinner, it shows what is coming and where, so it only earns its place when you genuinely know the shape of the content.

Use one when the content has a predictable shape (a card grid, a message list, a profile header, a product page) and the wait is long enough to notice. Jakob Nielsen’s long-standing response-time limits are a sound starting point: about 0.1 seconds feels instant, up to about 1 second keeps the user’s train of thought even though they notice the lag, and beyond 1 second people need a sign that the system is working. At around 10 seconds attention runs out, and Nielsen recommends a percent-done indicator and a way to cancel.

From those limits we use a rule of thumb rather than a hard law. Show nothing for loads that usually finish in under roughly 300ms, because a skeleton that appears for a frame or two reads as flicker, not progress. Show a skeleton for waits from about 300ms up to several seconds. For operations that regularly take ten seconds or more, switch to real progress with a cancel button.

Spinners still have a job. Use one for actions with no predictable output shape (saving settings, submitting a form, a search whose result count is unknown) and inside buttons, where a skeleton makes no sense.

Match the final layout, block for block

A skeleton is a promise about layout, and breaking it is worse than not making it. If the placeholder card is 280px tall and the real card is 340px, everything below it jumps 60px when the data lands. Users feel that jump, and it counts against your Cumulative Layout Shift score, as explained in our Core Web Vitals guide. The aim is that swapping skeleton for content changes pixels, not positions.

Three habits get you most of the way. First, build the skeleton from the same layout rules as the real component (grid, gap, padding, border radius) and only replace the leaves. Second, give media a fixed shape with aspect-ratio, which the real image should have anyway. Third, size text bars from the typography instead of guessing pixel heights. The lh unit, one computed line height, has worked in every current engine since late 2023 and makes this exact: a bar 0.7em tall with the leftover space split above and below occupies precisely one line.

.sk-theme {
  --sk-base: rgba(148, 163, 184, 0.16);
  --sk-shine: rgba(255, 255, 255, 0.1);
  --sk-radius: 6px;
}
.sk-block {
  position: relative;
  overflow: hidden;
  background: var(--sk-base);
  border-radius: var(--sk-radius);
}
.sk-media { aspect-ratio: 16 / 9; }
.sk-line {
  height: 0.7em;
  margin-block: calc((1lh - 0.7em) / 2);
}
.sk-short { width: 60%; }

Because a bar inherits font-size and line-height, dropping .sk-line inside an element that carries your heading styles gives a heading-sized line automatically. In the demos those wrappers are .sk-card-title and .sk-card-text; in a real project, reuse the component’s own typography classes so the two can never drift apart. Make the last bar shorter, around 60%, so it reads as the end of a paragraph. Do not try to predict the exact line count of unknown text, but if it is clamped to two lines, use exactly two.

Two ways to shimmer, and what each costs

The shimmer tells people that loading is active rather than stalled. There are two common ways to build it, and they give the browser very different amounts of work.

The first, used in most snippets including our own library skeleton, layers a gradient over the block and animates background-position. It is short and needs no extra elements. The catch is that browsers cannot hand background-position to the compositor, so each frame the main thread recalculates style and repaints the block before compositing. web.dev’s guide to high-performance animations is blunt: avoid any property that triggers layout or paint unless it is absolutely necessary. For three small bars the cost is negligible. For a feed of twenty cards it means continuous repainting of a large area, and because that work sits on the main thread, the shimmer stutters exactly when the page is busiest: while it parses the response and renders the content.

.sk-sweep {
  background: linear-gradient(90deg, transparent 25%, var(--sk-shine) 50%, transparent 75%) var(--sk-base);
  background-size: 200% 100%;
  animation: sk-bg 1.6s linear infinite;
}
@keyframes sk-bg {
  from { background-position: 100% 0; }
  to   { background-position: -100% 0; }
}

The second puts a translucent gradient on an ::after pseudo-element, clips it with the block’s overflow: hidden, and moves it with transform: translateX(). The block is painted once. After that the browser gives the moving pseudo-element its own layer, and the compositor only shifts that already-rasterised layer each frame, so the sweep keeps running smoothly while JavaScript is busy, which is precisely when a loading animation is on screen. Our guide to CSS animation performance walks through the rendering pipeline behind this.

.sk-shimmer::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--sk-shine), transparent);
  animation: sk-sweep 1.6s ease-in-out infinite;
}
@keyframes sk-sweep {
  to { transform: translateX(100%); }
}

Be precise about what you gain. The transform version is not free: every animating pseudo-element becomes a compositor layer that uses GPU memory, so a page with hundreds of shimmering bars still deserves simplifying. And because translateX(100%) is relative to each block’s own width, the shine crosses a narrow bar more slowly, in pixels per second, than a wide image. Most people never notice; if you do, put a single ::after on the card instead of on every bar. You can check either version yourself in Chrome DevTools: open the Rendering panel and turn on Paint flashing. The background-position blocks flash green continuously; the transform blocks do not.

TechniqueProperty animatedWhat the browser does each frameCostSupport
Gradient sweepbackground-positionRecalculates style, repaints every animating block on the main thread, then compositesGrows with the painted area; drops frames whenever the main thread is busyAll browsers
Pseudo-element shimmertransform on ::afterCompositor moves a layer that was painted once; no style or paint workOne layer per animating block (GPU memory); unaffected by main-thread workAll browsers
Opacity pulseopacityCompositor changes the layer’s opacity; no repaintCheapest of the three, especially on one wrapper; no movementAll browsers

The reduced-motion alternative: a gentle pulse

A sweeping band is motion across the screen, and for people with vestibular disorders large or repeated movement can cause real discomfort. The prefers-reduced-motion media feature reports the operating-system setting (Reduce motion on macOS and iOS, Animation effects on Windows 11, Remove animations on Android). MDN’s prefers-reduced-motion reference describes it as a request to remove, reduce or replace motion-based animation, and notes that it has been supported across browsers since January 2020.

For skeletons, replace rather than remove. Hide the sweep and slowly fade the blocks between full and partial opacity. Nothing travels across the screen, yet the page still visibly shows it is working, which a frozen grey shape does not. Opacity is compositor-friendly too, so the fallback costs no more than the transform shimmer. If a component pulses all the time, as in the third demo, put the animation on the wrapper: one animated element instead of one per bar. And if your users find even a pulse too busy, animation: none is a valid choice, since a static skeleton still reserves the layout.

.sk-pulse { animation: sk-pulse 1.8s ease-in-out infinite; }
@keyframes sk-pulse {
  50% { opacity: 0.45; }
}

@media (prefers-reduced-motion: reduce) {
  .sk-shimmer::after { display: none; }
  .sk-shimmer,
  .sk-sweep {
    background: var(--sk-base);
    animation: sk-pulse 1.8s ease-in-out infinite;
  }
}

Dark mode with custom properties

Every colour in the demos comes from two custom properties, --sk-base for the block and --sk-shine for the highlight, so theming is a matter of redefining them. Translucent values help: a 16% slate block reads as a soft grey on most dark surfaces without being tuned to one exact background. On a light page the balance flips. The base needs a faint dark tint and the shine a strong white, because a weak white highlight disappears against a pale block. This site is dark-only, so the demos ship just the dark pair; a site with both themes would set light values by default and override them in a prefers-color-scheme block, or under whatever theme attribute your toggle sets.

.sk-theme {
  --sk-base: rgba(15, 23, 42, 0.08);
  --sk-shine: rgba(255, 255, 255, 0.6);
}
@media (prefers-color-scheme: dark) {
  .sk-theme {
    --sk-base: rgba(148, 163, 184, 0.16);
    --sk-shine: rgba(255, 255, 255, 0.1);
  }
}

Make it accessible

To sighted users a skeleton says “loading”. To a screen reader, a stack of empty div elements says nothing useful. Three pieces fix that.

Mark the region being loaded with aria-busy="true". MDN’s aria-busy reference explains that assistive technology may wait until the changes are complete before exposing the content, and that you should set the attribute back to false once loading finishes, so content that arrives in several parts does not interrupt the user repeatedly.

Hide the decorative blocks with aria-hidden="true". They carry no information, and announcing them is noise.

Announce the state in words with a visually hidden role="status" element, which is a polite live region: its new text is read out without interrupting. Two details matter. Live regions announce changes, not text that is already present when the page loads, so ship the element empty and set its text when loading starts. And keep it outside the busy region, because content inside an element marked busy may be held back until loading ends, which defeats a loading message.

<p id="articles-status" class="visually-hidden" role="status"></p>

<section id="articles" class="card-grid"></section>

<template id="articles-skeleton">
  <div class="sk-theme sk-card" aria-hidden="true">
    <div class="sk-block sk-media sk-shimmer"></div>
    <div class="sk-card-title">
      <div class="sk-block sk-line sk-shimmer"></div>
    </div>
    <div class="sk-card-text">
      <div class="sk-block sk-line sk-shimmer"></div>
      <div class="sk-block sk-line sk-short sk-shimmer"></div>
    </div>
  </div>
</template>

Swap, don’t stack

When the data arrives, replace the skeleton. Do not render the content on top of it, or beside it while the skeleton fades out. Stacking risks a double-height flash, keeps the shimmer animating behind real content, and leaves orphaned nodes for assistive technology. replaceChildren() swaps everything in one operation, and the same script can apply the 300ms rule, so a fast response never shows a skeleton at all.

const region = document.getElementById('articles');
const status = document.getElementById('articles-status');
const skeleton = document.getElementById('articles-skeleton');

const timer = setTimeout(() => {
  region.setAttribute('aria-busy', 'true');
  region.replaceChildren(skeleton.content.cloneNode(true));
  status.textContent = 'Loading articles…';
}, 300);

const articles = await fetchArticles();
clearTimeout(timer);
region.replaceChildren(...articles.map(renderCard));
region.setAttribute('aria-busy', 'false');
status.textContent = `${articles.length} articles loaded`;

One caveat on the fast path: if the region sits above other content and starts empty, the real cards still push things down when they arrive. Give the region a min-height close to its typical height so both paths, skeleton or no skeleton, land without a shift. Also handle failure: if the request throws, swap the skeleton for an error message and a retry button, and put the error in the status text. A shimmer that never ends is the most misleading loading state of all.

Frequently asked questions

Is a skeleton screen better than a spinner?

When you know the shape of the content and the wait is longer than about 300ms, yes: a skeleton shows what is coming and where, and it reserves the space so nothing jumps. For actions with no predictable result, such as saving a form, or inside a button, a spinner is still the right choice.

Why does my skeleton shimmer stutter?

It is probably animating background-position, which makes the browser repaint every block on the main thread each frame. While your JavaScript is parsing data and rendering content, those frames get dropped. Move a gradient on a pseudo-element with transform instead, which the compositor can animate without repainting.

How do I make a skeleton loader accessible?

Set aria-busy="true" on the region being loaded, hide the grey blocks with aria-hidden="true", and put the words Loading articles into a visually hidden role="status" element that sits outside the busy region. When the content arrives, set aria-busy back to false and update the status text.

Do skeleton screens affect Cumulative Layout Shift?

They can improve it or make it worse. A skeleton that matches the final layout reserves space, so content arrives without moving anything. A skeleton that is shorter or taller than the real content causes a shift at the moment of the swap, and that shift counts towards CLS.