← Back to gallery
CSS

Shimmer Skeleton Loaders

Skeleton placeholders over a static block layout — loading is communicated by the motion layer above. Three motion archetypes: a stage-wide dual sheen, a per-block sweep across feed cards, and a per-row opacity wave on console rows.

shimmerskeleton-loaderplaceholderlinear-gradientbackground-positionrow-staggerprefers-reduced-motion

Skeleton placeholder · gradient sheen

Shimmer Gradient Sweep Skeleton

Three production-realistic skeleton loader patterns over a stable block layout. Each variant exercises a visibly different motion mechanic — a single horizontal sheen, per-block opacity pulse with row stagger, and a dual-layer sheen at two speeds — without importing any external animation asset.

Stage-wide sheen · profile card

Frost Profile

A bright sheen band drifts across the whole stage on a steeper 132° angle. Surface elements (avatar, copy stack) and the large hero block share the same solid base background, so a single band sweeps across all of them at once and the colour reads identically across the card.

  • stage-wide sheen
  • glassy depth
  • profile card

Per-block sheen · feed cards

Daybreak Feed

Each block (avatar, headline, media, chips, buttons) carries its own gradient sheen and fades out between passes — a low-contrast band sweeps across each block individually, classic news-feed skeleton energy with a subtle vanishing accent on every loop.

  • feed rows
  • per-block sheen
  • fade-out accent

Row pulse · console rows

Signal Console

Every block animates its own opacity (full → dim → full) with a per-row delay so the stack reads as data streaming in row by row top-to-bottom. No moving sheen — purely opacity-based, ideal for dense panels where a sweep would compete with the structure.

  • per-row stagger
  • opacity pulse
  • streaming feel

Shimmer inspector

Frost Profile

  • stage-wide sheen
  • glassy depth
  • profile card

A bright sheen band drifts across the whole stage on a steeper 132° angle. Surface elements (avatar, copy stack) and the large hero block share the same solid base background, so a single band sweeps across all of them at once and the colour reads identically across the card.

Helped you ship something? 🐟 Send my cat a churu

/* Two layered sheens — fast tight ::after band over a slow soft ::before band travelling the other way — for a glassy crossing-light feel. */
.shimmer-preview {
  --shimmer-duration: 3.00s;
  --shimmer-highlight: rgba(209, 239, 255, 0.46);
  --shimmer-soft: rgba(126, 174, 215, 0.19);
  position: relative;
  overflow: hidden;
}

/* Fast tight bright band, right → left */
.shimmer-preview::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    var(--shimmer-angle, 132deg),
    transparent 30%,
    var(--shimmer-highlight) 50%,
    transparent 70%
  );
  background-size: 220% 100%;
  animation: shimmerSweep var(--shimmer-duration) linear infinite;
  pointer-events: none;
}

/* Slow soft wide band, left → right (1.6× slower) */
.shimmer-preview::before {
  content: '';
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    var(--shimmer-angle, 132deg),
    transparent 16%,
    var(--shimmer-soft) 50%,
    transparent 84%
  );
  background-size: 220% 100%;
  animation: shimmerSweepReverse calc(var(--shimmer-duration) * 1.6) linear infinite;
  pointer-events: none;
}

@keyframes shimmerSweep        { from { background-position: 150% 0; }  to { background-position: -130% 0; } }
@keyframes shimmerSweepReverse { from { background-position: -130% 0; } to { background-position: 150% 0; } }

@media (prefers-reduced-motion: reduce) {
  .shimmer-preview::before,
  .shimmer-preview::after { animation: none; }
}

How to make this

A CSS skeleton loader is a moving gradient on a placeholder block — animate background-position across a 200%-wide linear-gradient with a single bright highlight stop.

html
1<div class="skeleton" aria-busy="true" aria-live="polite">  <div class="skeleton__line skeleton__line--title"></div>  <div class="skeleton__line"></div>  <div class="skeleton__line skeleton__line--short"></div></div> <style>.skeleton {  display: grid;  gap: 0.5rem;  padding: 1rem;  max-width: 320px;}.skeleton__line {  height: 0.75rem;  border-radius: 4px;17  background: linear-gradient(    90deg,    #e9eef5 0%,    #f5f7fa 40%,    #ffffff 50%,    #f5f7fa 60%,    #e9eef5 100%  );25  background-size: 200% 100%;  animation: shimmer 1.5s linear infinite;27}.skeleton__line--title { height: 1rem; width: 60%; }.skeleton__line--short { width: 40%; } 31@keyframes shimmer {  from { background-position: 200% 0; }  to   { background-position: -200% 0; }} 36@media (prefers-reduced-motion: reduce) {  .skeleton__line { animation: none; }}</style>

Annotated snippet

  1. Line 1aria-busy="true" tells assistive tech this region is loading. Flip it to "false" (or remove the skeleton from the DOM) once real content arrives so screen readers announce the loaded state.
    PitfallDo screen readers need to know a skeleton is a loading state?

    Yes. Wrap the skeleton in an element with aria-busy="true" and aria-live="polite". Once the real content has loaded, swap the skeleton DOM out for the content — at that point aria-busy goes to "false" implicitly because the busy node is gone. Without aria-busy, assistive tech can try to interpret the placeholder bars as empty content and announce nothing useful.

  2. Line 17The 5-stop linear-gradient is the moving sheen. A bright highlight stop (#ffffff at 50%) sits between two softer base stops — that gives the band a clear leading and trailing edge.

    Drop the bright middle stop and there is no sheen to slide — just a soft horizontal fade.

  3. Line 25background-size: 200% means the gradient canvas is twice as wide as the element, so the highlight has somewhere to slide in from and out to.

    At 100%, the gradient is the same width as the bar — there is no off-screen runway, so the sheen flashes in and out instead of sliding through.

  4. Line 271.5s per cycle is the practical sweet spot — fast enough to feel responsive, slow enough to avoid distracting flicker. Below ~1s starts to feel anxious.
  5. Line 31Animate background-position, not transform on the box. The skeleton keeps its place in layout; only the painted gradient slides across.

    Animate the box itself with transform and the entire bar drifts out of its slot — only the paint should move, not the placeholder.

    PitfallWill animating background-position hit 60fps?

    background-position triggers paint, not composite, on every frame. For a handful of skeleton rows on a modern device, this is fine. At scale — say, fifty skeleton rows on a low-end mobile feed — the paint cost can drop frames. If you need many skeletons at once, replace the per-row animated background with a single absolutely-positioned overlay div that uses transform: translateX(). Transform-based animation runs on the compositor and stays smooth at high counts.

  6. Line 36The reduced-motion branch sets animation: none — the gradient stays visible as a static neutral fill, which is correct skeleton UX. Do NOT display: none the skeleton itself; users with reduced motion still need to see that content is absent.
    PitfallHow do I make a CSS skeleton loader respect prefers-reduced-motion?

    Wrap an @media (prefers-reduced-motion: reduce) block around the animated rule and set animation: none on the skeleton element. The gradient stays visible as a static neutral fill — that is correct skeleton UX, because users with reduced motion still need to see that content has not loaded yet. Do not hide the skeleton entirely under reduced motion; that creates a confusing blank region.

Other pitfalls

How do I make a CSS skeleton look right in dark mode?
Do not reuse the light-mode gradient with a pure white (#ffffff) highlight on a dark surface — the contrast band looks harsh and the eye reads it as a glitch rather than a loading state. In dark mode, use a tinted highlight at low alpha (for example rgba(255, 255, 255, 0.08)) sitting over a slightly lighter version of the dark surface color. The same gradient structure works; only the three colour stops change.
Advanced

Layer a colored trailing band on top of the base sweep

Same base sweep underneath. BEFORE leaves the single white highlight on its own — the placeholder feels generic. AFTER adds a cyan trailing band on a ::after pseudo, sweeping at the same 1.5s speed but with a 400ms delay so it follows behind the main highlight. The result reads as a branded "two-pass" loader without changing the underlying skeleton structure.

View explanation and full code29 lines

A single white sweep reads as a generic placeholder. Production skeleton loaders in branded UIs often layer a second narrower band in a brand-accent color, sweeping at the same speed but phase-offset by a small delay — a "lead highlight + colored trail" pair that signals identity without changing the loading semantics. The ::after pseudo carries the second band on its own animation-delay; the underlying skeleton stays semantically and visually identical.

Append these rules inside the <style> block from the base snippet above.

css
/* Advanced: colored trailing band — extends the base recipe. */.skeleton__line {  position: relative;  isolation: isolate;}.skeleton__line::after {  content: '';  position: absolute;  inset: 0;  border-radius: inherit;  background: linear-gradient(    90deg,    transparent 35%,    rgba(125, 211, 252, .42) 50%,    transparent 65%  );  background-size: 200% 100%;  background-repeat: no-repeat;  animation: shimmer-trail 1.5s linear .4s infinite;  pointer-events: none;  mix-blend-mode: screen;}@keyframes shimmer-trail {  from { background-position: 200% 0; }  to   { background-position: -200% 0; }}@media (prefers-reduced-motion: reduce) {  .skeleton__line::after { animation: none; }}

Notes

Overview

CSS shimmer skeleton loaders fill the layout shape of incoming content with muted blocks that shimmer to indicate work is happening. The sweep is a linear gradient with a bright center stop that background-position animates across each block. The skeleton itself stays static — only the motion layer above it moves — so layout never shifts when the real content swaps in.

When to use it

Reach for shimmer skeletons on any content that arrives async and has a predictable layout shape (feed cards, profile rows, product tiles, chat-message lists). Skip them for content under 200ms load time — the skeleton appears and disappears so fast it looks like a glitch. Skip them when the real content has wildly variable shape; a skeleton that does not match the real layout causes a layout shift on swap, which is worse than no skeleton at all.

How it works

Each skeleton block has background: linear-gradient(90deg, neutral, highlight, neutral) with background-size: 200% 100% so the gradient is wider than the block. A @keyframes animation moves background-position from 200% 0 to -200% 0 (or similar) over ~1.4s, so the bright band sweeps left-to-right repeatedly. Multiple blocks can share the same animation with staggered delays for a wave effect. The skeleton geometry (avatar, line widths, media block heights) is hand-authored to match the real content’s layout so the swap is invisible.

The shimmer layer should be treated as an affordance, not the loading state itself. The actual skeleton loader is the stable geometry: reserved media blocks, avatar circles, and text rows sized to match the incoming content. The gradient sweep only tells the user that the placeholder is alive; it cannot fix a placeholder layout that does not match the real DOM.

Production gotchas

Skeleton-to-real swap must preserve layout exactly — if the real content has different padding or line-height than the skeleton, the swap visibly jumps. Animate background-position, not transform: translateX on a separate sheen element; sheen-element approaches require an extra layer that can repaint outside its parent. On lower-end mobile the gradient sweep cost across many simultaneous skeletons can drop frames — cap the visible skeletons to what fits in viewport, or pause off-screen ones via content-visibility: auto.

Accessibility

Skeletons are decorative; wrap them in aria-hidden="true" and pair with anaria-live="polite" announcement when the real content arrives (e.g. "Posts loaded."). Under prefers-reduced-motion: reduce freeze the sweep at its rest position; the muted blocks alone still communicate “loading” without the motion.

References

Implementation depth

The skeleton is the reserved geometry, not the shimmer. Match avatar size, media ratio, row count, and line widths to the loaded content first; then use linear-gradient motion as a progress affordance.

Pause work you cannot see. Many simultaneous background-position animations can cost real battery on long feeds, so combine content-visibility, viewport limits, and reduced motion to keep loading states quiet.