CSS Skeleton Loader

An animated placeholder loading state built from CSS keyframes and a moving gradient, with no JavaScript.

Published May 19, 2026 Intermediate 7 min read

A CSS skeleton loader stands in for content that has not arrived, using gray blocks in the shape of the real thing and a highlight that sweeps across them. The whole effect is two background layers and one @keyframes rule: a solid color for the block, a translucent band for the highlight, and an animated background-position to move it.

Two details decide whether it works. The highlight has to contrast with the block, because a sweep built from two nearly identical grays animates perfectly and remains invisible. And the placeholder boxes have to match the sizes of the content replacing them, or the page jumps when the data lands. Both are covered below, along with a cheaper pulse and a side by side comparison of a skeleton with its loaded state.

Placeholders with a moving highlight

HTML

<div class="skeleton-card">
  <div class="skel skel-avatar"></div>
  <div class="skel skel-line w-full"></div>
  <div class="skel skel-line w-3q"></div>
  <div class="skel skel-line w-half"></div>
</div>

CSS

.skeleton-card {
  width: 240px;
  padding: 1rem;
  background: #1c1c1e;
  border: 1px solid #2a2a2d;
  border-radius: 10px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, .35);
}

/* A solid block color plus a separate highlight image. Two grays that
   differ only slightly will animate but stay invisible against the card,
   so the highlight needs real contrast against the block underneath. */
.skel {
  background-color: #2a2a2d;
  background-image: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 20%,
    rgba(255, 255, 255, 0.18) 50%,
    rgba(255, 255, 255, 0) 80%
  );
  background-size: 200% 100%;
  background-repeat: no-repeat;
  border-radius: 6px;
  animation: shimmer 1.5s linear infinite;
}

.skel-avatar {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  margin-bottom: .75rem;
}

.skel-line { height: 12px; margin-bottom: .5rem; }
.w-full { width: 100%; }
.w-3q   { width: 75%; }
.w-half { width: 50%; }

@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Other ways to build it

A pulse instead of a sweep

Fading the whole block in and out replaces a paint on every frame with a compositor operation, which matters once a screen holds twenty placeholders rather than two. It also degrades better: a pulse that stops under reduced motion still leaves a recognisable skeleton, where a stopped sweep leaves a highlight frozen in an arbitrary position. The keyframe here has a new name, because the sweep's is shared with the card preview on the home page.

HTML

<div class="skel-pulse-row">
  <div class="skeleton-card skel-pulse-card">
    <div class="skel-p skel-p-avatar"></div>
    <div class="skel-p skel-p-line skel-w-100"></div>
    <div class="skel-p skel-p-line skel-w-75"></div>
    <div class="skel-p skel-p-line skel-w-45"></div>
  </div>
  <div class="skeleton-card skel-pulse-card">
    <div class="skel-p skel-p-avatar"></div>
    <div class="skel-p skel-p-line skel-w-100"></div>
    <div class="skel-p skel-p-line skel-w-75"></div>
    <div class="skel-p skel-p-line skel-w-45"></div>
  </div>
</div>

CSS

.skel-p {
  background: #2a2a2d;
  border-radius: 6px;
  /* opacity is a compositor property: no repaint per frame */
  animation: skel-pulse 1.6s ease-in-out infinite;
}

@keyframes skel-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: .45; }
}

/* stopped, it is still a readable skeleton */
@media (prefers-reduced-motion: reduce) {
  .skel-p {
    animation: none;
  }
}

Matching the shape of the content it replaces

The two panels below hold identical box geometry. Every placeholder is the same height, width and corner radius as the element that will take its place, and the gaps between them match too, so swapping one for the other moves nothing on the page. Getting this wrong is the most common skeleton bug: a twelve pixel placeholder line under a twenty four pixel heading looks fine on its own and shifts the whole page down when the real text arrives.

Loading
Loaded
Trellick Tower
Added 3 days ago
Open
Barbican
Added 6 days ago
Open

HTML

<div class="skel-compare">
  <div class="skel-panel">
    <span class="skel-panel-tag">Loading</span>
    <div class="skel-row">
      <div class="skel skel-thumb"></div>
      <div class="skel-body">
        <div class="skel skel-title"></div>
        <div class="skel skel-meta"></div>
      </div>
      <div class="skel skel-action"></div>
    </div>
    <div class="skel-row">
      <div class="skel skel-thumb"></div>
      <div class="skel-body">
        <div class="skel skel-title skel-w-75"></div>
        <div class="skel skel-meta"></div>
      </div>
      <div class="skel skel-action"></div>
    </div>
  </div>
  <div class="skel-panel">
    <span class="skel-panel-tag">Loaded</span>
    <div class="skel-row">
      <div class="skel-thumb skel-thumb-real"></div>
      <div class="skel-body">
        <div class="skel-title skel-title-real">Trellick Tower</div>
        <div class="skel-meta skel-meta-real">Added 3 days ago</div>
      </div>
      <div class="skel-action skel-action-real">Open</div>
    </div>
    <div class="skel-row">
      <div class="skel-thumb skel-thumb-real"></div>
      <div class="skel-body">
        <div class="skel-title skel-title-real">Barbican</div>
        <div class="skel-meta skel-meta-real">Added 6 days ago</div>
      </div>
      <div class="skel-action skel-action-real">Open</div>
    </div>
  </div>
</div>

CSS

/* the placeholder and the real element share every box value,
   so nothing moves when one replaces the other */
.skel-thumb,
.skel-thumb-real {
  width: 56px;
  height: 42px;
  border-radius: 6px;
  flex-shrink: 0;
}

.skel-title,
.skel-title-real {
  height: 17px;
  font-size: .85rem;
  line-height: 17px;
}

.skel-meta,
.skel-meta-real {
  height: 14px;
  font-size: .7rem;
  line-height: 14px;
  width: 60%;
}

.skel-action,
.skel-action-real {
  width: 54px;
  height: 26px;
  line-height: 26px;
  border-radius: 999px;
  flex-shrink: 0;
}

How it works

The shimmer uses a gradient background-image with a background-size of 200%, so the image is twice the width of the element. Animating background-position slides that oversized image across, which drags the highlight band from one edge to the other. Underneath it sits a solid background-color, and the contrast between the two is what makes the sweep visible at all.

The block color and the highlight are separate layers on purpose. An earlier version of this demo built the whole thing from one gradient between two grays that differed by a few percent, and it animated perfectly while being completely invisible against the card behind it. Splitting them fixes that: background-color sets the block, and background-image carries a highlight made of translucent white, which keeps real contrast against whatever color the block happens to be.

The positioning maths is worth understanding, because the numbers look arbitrary otherwise. A percentage in background-position aligns that point of the image with the same point of the positioning area, so the offset works out as the container width minus the image width, multiplied by the percentage. With background-size: 200% the image is twice the container, so that difference is negative, and 200% pushes the image two widths to the left while -200% pushes it two widths right. Animating between them is what carries the band all the way across and off the other side.

The skeleton has to be the same shape as what it replaces. A twelve pixel placeholder line standing in for a twenty four pixel heading means everything below it moves when the real text arrives, which is a layout shift the reader sees and a metric that gets measured. Match heights, match widths, match the gaps, and the swap is invisible. The second variant on this page puts a skeleton next to its loaded state so the boxes can be compared directly.

background-position is a paint property rather than a compositor one, so every frame of the sweep repaints every skeleton element on screen. One card is nothing. Twenty rows shimmering at once on a mid range phone is measurable, and the cheaper alternative is a pulse on opacity, which the compositor handles without repainting anything. That is the first variant below.

The highlight is white because this page is dark, and a white overlay on a light theme does nothing at all. This stylesheet swaps it for a dark overlay when the site's light theme is active, which is a small reminder that a component built from translucent overlays has a direction as well as a color. Theme swaps of this kind are covered on CSS dark and light mode. The same invisible sweep problem shows up in the shimmering fill on CSS progress bars.

CSS properties used

background-color
The solid block underneath. Separating it from the highlight is what stops the whole effect collapsing into one flat gray.
background-image
The highlight band, built from a gradient that runs from transparent to translucent white and back.
background-size
200% 100% makes the image twice the element's width, which is what gives the band somewhere to travel from and to.
background-repeat
no-repeat stops the oversized gradient tiling, which would put a second highlight on screen at the same time.
background-position
The only animated value. Percentages resolve against the container width minus the image width, so 200% and -200% sit far off either edge.
border-radius
50% for an avatar placeholder, a few pixels for a text line. Matching the real element's radius is part of matching its shape.
opacity
The alternative animation. Pulsing it is a compositor operation, where sliding a background is a repaint on every frame.

Browser support

FeatureChromeFirefoxSafariEdge
multiple backgrounds43.63.112
animation455.112
gradients103615.412
border-radius433.112
:has()10512115.4105
prefers-reduced-motion746310.179

Figures come from Can I Use for Multiple backgrounds, CSS Animation, CSS Gradients, Border-radius, the :has() relational pseudo-class and the prefers-reduced-motion media query. Only the last two are recent, and both fail safely: without :has() the highlight keeps its dark theme direction, and without the media query the sweep simply runs for everyone. The gradient figure reads late because Can I Use counts full support only once the entire modern syntax is present, and the two stop gradients here have worked far longer.

Accessibility notes

Gray rectangles mean nothing to a screen reader. Mark the container aria-busy="true" while it is loading and hide the placeholders themselves with aria-hidden="true", then announce the state in words through a role="status" region, such as "Loading results". Without that, someone using a screen reader hears an empty region and has no way to tell loading from broken.

A shimmer is motion that starts on its own and does not stop, which is exactly what prefers-reduced-motion is for. Remove the animation in that media query and leave the blocks solid. The skeleton still communicates that content is coming, because its shape is doing that work rather than its movement.

Do not leave a skeleton up when a request fails. A permanent placeholder is indistinguishable from a hung page, and it is worse than an error message because it gives the reader nothing to act on. Give every skeleton a timeout that replaces it with real text explaining what went wrong.

What you can build with it

  • Feed and list loading. Rows of placeholders matching the real row geometry, so the list does not jump when the response arrives.
  • Card grids. The demo above. Image block, heading line, two body lines, in the same arrangement the loaded card uses.
  • Table rows. One placeholder per column, at the column widths, which keeps the header alignment stable while data loads.
  • Profile headers. A round avatar placeholder and two lines, the pattern almost every social product uses above the fold.
  • Detail panes. A skeleton in a side panel while the record loads, so the panel width and scroll position are settled before the content lands.

Mistakes worth avoiding

  • Building the sweep from two nearly identical colors. It animates correctly, the browser reports no problem, and nothing is visible. The highlight needs real contrast against the block.
  • Leaving background-repeat at its default. The oversized gradient tiles, so a second highlight appears on screen and the sweep reads as a flicker.
  • Sizing placeholders differently from the real content. The layout shifts when data arrives, which the reader notices and page quality metrics record.
  • Animating a background on dozens of elements at once. Each frame repaints all of them, where a pulse on opacity would have been handled by the compositor.
  • Showing a skeleton with no timeout. A failed request leaves a placeholder shimmering forever, which reads as a hang rather than an error.

Frequently asked questions

Why is my skeleton shimmer not visible?
Almost always contrast. If the gradient runs between two grays a few percent apart, the animation runs perfectly and there is nothing to see. Use a solid background-color for the block and a separate background-image whose highlight is translucent white, or translucent black on a light theme.
How does the background-position animation work?
background-size: 200% makes the gradient twice the element's width. A percentage position aligns that point of the image with the same point of the positioning area, and since the image is wider than the box, the offset is negative. 200% therefore parks the image two widths left and -200% two widths right, so animating between them drags the highlight across.
Should a skeleton match the real content exactly?
Match the boxes, not the detail. Same heights, same widths, same gaps and the same corner radius, so nothing moves when the content replaces it. What you should not do is guess at the exact text length, since a placeholder line at seventy percent width reads better than one pretending to be a specific sentence.
Is a skeleton loader better than a spinner?
For content with a known shape, usually. A skeleton says what is coming and where it will be, and it reserves the space so the page does not jump. A spinner is the better choice when the result has no predictable layout, or when the wait is short enough that a skeleton would flash in and out.
How do I make a skeleton loader accessible?
Set aria-busy="true" on the container, aria-hidden="true" on the placeholder blocks, and announce the state in text through a role="status" region. Remove the animation under prefers-reduced-motion, and never leave the skeleton in place after a failed request.