TH The Loading Loop
Skeletons & Progress

CSS Skeleton Loader Without Layout Shift

CSS Skeleton Loader Without Layout Shift
tldrBuild a CSS skeleton loader on the same grid as the final component, reserving media with `aspect-ratio` and text regions with realistic minimum dimensions. Hide stand-in shapes from assistive technology, mark the updating region `aria-busy="true"`, and provide a separate status message. For optional shimmer, move a pseudo-element with `transform`; stop the animation under `prefers-reduced-motion` and replace the skeleton on success or error.

A skeleton should reserve the content's real shape

A CSS skeleton loader is a temporary visual stand-in whose boxes match the dimensions of incoming content. Build the stand-in and final component on the same grid, reserve media with aspect-ratio, give text regions predictable minimum heights, hide decorative skeleton shapes from assistive technology, and expose a separate loading status. Animate a pseudo-element with transform for optional shimmer, then disable it for reduced motion.

The pattern below creates a card whose loading and loaded states occupy the same basic footprint.

Complete HTML for loading and loaded states

Show only one card state at a time. The first is the loading state:

<article class="card card--loading" aria-busy="true">
  <div class="skeleton card__media" aria-hidden="true"></div>

  <div class="card__body" aria-hidden="true">
    <div class="skeleton skeleton--eyebrow"></div>
    <div class="skeleton skeleton--title"></div>
    <div class="skeleton skeleton--line"></div>
    <div class="skeleton skeleton--line skeleton--line-short"></div>
  </div>

  <p class="visually-hidden" role="status" aria-atomic="true">
    Loading article preview…
  </p>
</article>

When data arrives, replace it with the final content and clear the busy state:

<article class="card" aria-busy="false">
  <img
    class="card__media"
    src="garden-path.jpg"
    alt="Curving gravel path through a green garden"
    width="800"
    height="450"
  >

  <div class="card__body">
    <p class="card__eyebrow">Garden Design</p>
    <h2 class="card__title">How to Plan a Garden Path</h2>
    <p class="card__summary">
      Choose a useful route, comfortable width, and surface that suits the site.
    </p>
  </div>
</article>

In a real application, preserve the live-region container outside the replaced subtree if you need it to announce both loading and completion reliably. The example keeps the state markup readable; your framework should update DOM and focus according to the interaction.

Complete CSS

.card {
  --card-radius: 0.75rem;
  --skeleton-base: #e2e8f0;
  --skeleton-highlight: rgba(255, 255, 255, 0.65);

  display: grid;
  grid-template-rows: auto 1fr;
  width: min(100%, 22rem);
  overflow: hidden;
  border: 1px solid #cbd5e1;
  border-radius: var(--card-radius);
  background: #ffffff;
  color: #0f172a;
  font-family: system-ui, sans-serif;
}

.card__media {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: var(--skeleton-base);
}

.card__body {
  min-height: 10rem;
  padding: 1rem;
}

.card__eyebrow {
  margin: 0 0 0.5rem;
  color: #475569;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.card__title {
  min-height: 2.5em;
  margin: 0 0 0.75rem;
  font-size: 1.25rem;
  line-height: 1.25;
}

.card__summary {
  margin: 0;
  color: #334155;
  line-height: 1.5;
}

.skeleton {
  position: relative;
  overflow: hidden;
  border-radius: 0.375rem;
  background: var(--skeleton-base);
}

.skeleton::after {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    var(--skeleton-highlight),
    transparent
  );
  content: "";
  transform: translateX(-100%);
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
}

.skeleton--eyebrow {
  width: 35%;
  height: 0.75rem;
  margin-bottom: 0.75rem;
}

.skeleton--title {
  width: 88%;
  height: 1.25rem;
  margin-bottom: 1rem;
}

.skeleton--line {
  width: 100%;
  height: 0.875rem;
  margin-bottom: 0.625rem;
}

.skeleton--line-short {
  width: 68%;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

@keyframes skeleton-shimmer {
  to {
    transform: translateX(100%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton::after {
    animation: none;
    transform: none;
  }
}

The loading card uses the same .card, .card__media, and .card__body geometry as the final card. The skeleton utility adds the stand-in fill and optional highlight.

Why this reduces layout shift

Layout shift occurs when visible content moves because the browser learns a missing size or the replacement content occupies different space. A skeleton helps only if it reserves dimensions close to the final component.

The image has three safeguards:

  1. Both states use .card__media.
  2. CSS reserves a 16 / 9 aspect ratio.
  3. The final <img> includes intrinsic width="800" and height="450", which express the same ratio because 800 ÷ 450 = 16 ÷ 9.

The body uses the same padding and a min-height of 10rem in both states. The title reserves at least 2.5em, which is two lines at line-height: 1.25: 2 × 1.25em = 2.5em.

Those choices do not guarantee zero shift. A three-line title, translated text, larger user font settings, or different final controls may require more space. Design skeletons from actual component variants, not from the shortest English card in the mockup.

Do not copy the text too literally

A skeleton should suggest structure without pretending to know the exact incoming sentence. Use a few varied bars, not one rectangle for every line of eventual copy. Dense shimmering paragraphs add visual noise and can make a wait feel longer.

Match stable component regions:

Do not skeletonize interface elements that are already available. If a card's menu can function before its content loads, consider keeping the real control rather than replacing it with inert gray confetti.

Make skeletons invisible to the accessibility tree

The stand-in shapes contain no content, so the example hides them with aria-hidden="true". The article carries aria-busy="true", signaling that the region is being updated, and a separate role="status" message announces the wait.

When content arrives:

Do not put fake headings, empty buttons, or meaningless image alt text inside a skeleton. Assistive technology should encounter the status, then the real content—not tour a museum of unnamed rectangles.

The full accessible loading indicators guide explains the differences between status, busy, and progress semantics.

Keep the shimmer restrained

The shimmer uses an absolutely positioned pseudo-element, so it does not affect layout. Its transform moves the highlight across the already-painted base. MDN's performance guidance notes that transforms and opacity can often be handled in composition rather than triggering layout; still, many large animated skeletons can consume CPU or GPU time.

Consider these limits:

The reduced-motion rule stops shimmer and leaves the base shapes visible. This preserves layout and state without perpetual movement. A static skeleton is still a skeleton; it has not lost its professional license.

Responsive skeleton layouts

If the final component changes at a breakpoint, change the skeleton through the same layout rules. For example:

@media (min-width: 48rem) {
  .card--horizontal {
    grid-template-columns: 10rem 1fr;
    grid-template-rows: 1fr;
    width: min(100%, 36rem);
  }

  .card--horizontal .card__media {
    height: 100%;
    aspect-ratio: auto;
  }
}

Apply card--horizontal to both loading and final cards. The grid change then affects the stand-in and content equally. Test the loaded image's crop with object-fit: cover; geometry can match while the chosen photograph still loses its subject at a narrow crop.

Skeleton, spinner, or progress bar?

Choose feedback based on the task:

Situation Better pattern Reason
Content card or feed is taking shape Skeleton Reserves expected layout
Small indeterminate action CSS spinner Compact acknowledgment
Inline conversational wait Three-dot loader Fits text rhythm
Known amount completed Native or ARIA progress bar Communicates actual progress
Empty result Empty state Nothing is loading
Failed request Error and recovery action Spinning would be false feedback

A skeleton is most useful when the content structure is predictable and the wait is noticeable. For a button action that finishes almost immediately, swapping the whole page into gray scaffolding is theatrical overreach.

Common skeleton mistakes

The final content is taller

Test long titles, localization, large text, optional badges, and error states. Use flexible minimum sizes rather than fixed heights where content can legitimately grow.

Images still cause a jump

Give the final image intrinsic width and height attributes and a matching CSS aspect ratio. Ensure loading and final states share the same grid placement.

Screen readers encounter blank content

Hide decorative shapes, expose a meaningful status, and mark the updating region busy. Verify that real content enters the accessibility tree when loading completes.

Shimmer looks jerky

Animate transform rather than background position, reduce the number of simultaneous skeletons, and profile the real page. A static reduced-motion state should also be available.

The loader remains after an error

Replace it with an error message and a retry or alternative action. A skeleton forecasts content; after failure, continuing the forecast becomes misinformation.

The best skeleton is an honest floor plan of the component that is coming. It reserves space, reports the wait, respects motion preferences, and vanishes cleanly when the real tenant arrives.

Verify the swap with real data

Run the component with the shortest title, the longest plausible title, missing optional media, translated copy, and increased text size. Record loading and loaded dimensions in developer tools; compare the card's top, bottom, and media box before and after replacement. Also throttle the request, force an error, and confirm the stand-in becomes useful error content rather than an abandoned construction site.

Test keyboard and screen-reader behavior across the swap. No stand-in should receive focus, the busy state must clear, and newly loaded links should enter the normal tab order exactly once. Visual stability and semantic stability are separate checks, and the component needs both.

FAQ

What is a CSS skeleton loader?

A skeleton loader is a temporary set of simple shapes that previews the layout of content still loading. It is useful for predictable cards, lists, and media areas. A good skeleton reserves nearly the same dimensions as the final content, remains decorative to assistive technology, and is paired with truthful loading status.

How does a skeleton loader prevent layout shift?

It reserves the final component's space before data and media arrive. Use the same grid, padding, media ratio, and realistic text-area minimums in loading and loaded states. Add intrinsic width and height to final images. Test long titles, localization, large text, optional controls, and errors because one ideal card cannot represent every variant.

Should skeleton loaders use ARIA labels?

Do not label every gray bar. Hide decorative skeleton shapes with `aria-hidden="true"`, mark the content region `aria-busy="true"`, and expose one useful status such as “Loading article previews…” in a live region. When content arrives, clear the busy state and ensure the real semantic content replaces the stand-ins.

How do I make a skeleton shimmer in CSS?

Place an absolutely positioned pseudo-element over the base skeleton, give it a transparent-to-light gradient, and animate `transform: translateX()` from one side to the other. Keep the parent positioned and clipped with `overflow: hidden`. Use a static base for reduced motion and test performance with the real number of visible stand-ins.

Are skeleton screens better than spinners?

They serve different situations. Skeletons are useful when the incoming layout is predictable and reserving its shape reduces visual movement. Spinners suit compact indeterminate actions whose final structure is already present. Use a progress bar when completion is measurable, an empty state when no content exists, and an error when the request fails.

Why does my page still shift after using a skeleton?

The stand-in likely does not match a real variant. Common causes include missing image dimensions, a different grid, fixed stand-in heights, long or translated text, large user font settings, badges, and late controls. Compare computed loading and loaded dimensions across realistic data, then share layout classes and reserve flexible minimum space.