---
name: shimmer-loader
description: Use when you want "Professional, polished, familiar" - Adds moving shine across loading placeholders.
---

# Shimmer Loader

> **Category:** Loading / Transition  -  **Personality:** Professional, polished, familiar
>
> **Best use:** Apps, e-commerce, feeds
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

A shimmer loader (a.k.a. a skeleton screen) fills the layout with grey placeholder blocks shaped like the real content, then sweeps a soft highlight across them so the UI reads as *actively loading* rather than frozen. When the data arrives, the placeholders cross-fade into the real images and text. It's the familiar pattern behind product grids, social feeds and app shells because it communicates the page's structure instantly and *feels* faster than a spinner — the user sees where things will land before they load. Use it whenever content takes more than a few hundred milliseconds to fetch.

## Dependencies / CDN

**None — the effect is pure CSS** (one `@keyframes` plus a gradient pseudo-element). The demo's timing loop is a few lines of vanilla JS; no library. Fonts are optional flourish:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
```

## HTML

Each card holds two layers in the same box: the **real** content (always in normal flow, so it defines the card's height) and an absolutely-positioned **skeleton** overlay that mirrors the same layout. A single `is-loading` flag on an ancestor decides which one is visible.

```html
<section class="stage is-loading">
  <article class="card">
    <!-- real content -->
    <div class="real">
      <div class="media"><img class="img" src="/img/coast.jpg" alt="Coastal Haze"></div>
      <div class="body">
        <span class="eyebrow">Maya Okonkwo</span>
        <h3 class="title">Coastal Haze</h3>
        <div class="foot"><span class="price">$58</span><button class="add">+</button></div>
      </div>
    </div>
    <!-- skeleton overlay (decorative) -->
    <div class="skel-layer" aria-hidden="true">
      <div class="media skel"></div>
      <div class="body">
        <span class="bar bar-eyebrow skel"></span>
        <span class="bar bar-title skel"></span>
        <div class="foot"><span class="bar bar-price skel"></span><span class="bar bar-btn skel"></span></div>
      </div>
    </div>
  </article>
</section>
```

## CSS

```css
/* 1) THE SHIMMER — the only lines that matter for the effect */
.skel{ position:relative; overflow:hidden; background:#e6ddcd; }   /* grey placeholder */
.skel::after{
  content:""; position:absolute; inset:0;
  transform:translateX(-100%);                                     /* park the band off-screen left */
  background:linear-gradient(100deg, transparent 18%,
                                     rgba(255,255,255,.92) 50%,     /* the moving highlight */
                                     transparent 82%);
  animation:sweep 1.45s ease-in-out infinite;                      /* synchronised across every .skel */
}
@keyframes sweep{ 100%{ transform:translateX(100%) } }             /* sweep across, then loop */

/* 2) STATE — skeleton vs. content, cross-faded. `is-loading` lives on the ancestor. */
.card{ position:relative }
.skel-layer{ position:absolute; inset:0; display:flex; flex-direction:column;
  background:#fbf8f1; opacity:0; pointer-events:none; transition:opacity .45s ease }
.real{ opacity:1; transition:opacity .5s ease, transform .6s cubic-bezier(.2,.7,.2,1) }

.is-loading .real      { opacity:0; transform:translateY(10px) }   /* hide content while loading */
.is-loading .skel-layer{ opacity:1 }                               /* show skeleton             */
.is-loading + … resolves automatically when the flag is removed.

/* Polish: stagger the reveal, settle the image, pause the sweep when idle */
.stage:not(.is-loading) .real,
.stage:not(.is-loading) .img { transition-delay:calc(var(--i,0)*70ms) }
.img{ transform:scale(1.06); transition:transform .85s cubic-bezier(.2,.7,.2,1) }
.stage:not(.is-loading) .img{ transform:scale(1) }
.stage:not(.is-loading) .skel::after{ animation-play-state:paused }

/* Placeholder bar shapes */
.bar{ display:block; border-radius:6px; background:#e6ddcd }
.bar-eyebrow{ height:9px;  width:44% }
.bar-title  { height:15px; width:84% }

/* Respect reduced motion: no sweep, no spin — show a calm static placeholder instead */
@media (prefers-reduced-motion: reduce){
  .skel::after{ animation:none; transform:translateX(0);
    background:linear-gradient(100deg, transparent, rgba(255,255,255,.4), transparent) }
  .img{ transform:none }
}
```

## JavaScript

Not required for the effect — only to drive the demo's loading cycle (initial load, the **Reload** button, category chips, and an **Auto-replay** toggle). It just adds/removes one class.

```js
const stage = document.querySelector('.stage');
const grid  = stage.querySelector('.grid');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const LOAD_MS = reduce ? 650 : 1500;
let loading = false, timer = null;

function run(){                               // enter the skeleton state
  clearTimeout(timer); loading = true;
  stage.classList.add('is-loading');
  grid.setAttribute('aria-busy','true');
  timer = setTimeout(finish, LOAD_MS);        // ...then resolve after the "fetch"
}
function finish(){                            // reveal the real content
  loading = false;
  stage.classList.remove('is-loading');
  grid.setAttribute('aria-busy','false');
}

document.querySelector('.reload').addEventListener('click', run);
// Auto-replay loop is gated behind prefers-reduced-motion:
let auto = null;
if(!reduce) toggle.addEventListener('change', e =>
  e.target.checked ? auto = setInterval(() => { if(!loading) run(); }, LOAD_MS + 2800)
                   : clearInterval(auto));

run(); // page paints in the skeleton state, then resolves once on load
```

## How it works

- **`transform: translateX()` on a `::after`** is the whole effect. The pseudo-element holds a wide `linear-gradient` whose bright centre is flanked by transparent edges; translating it from `-100%` to `100%` drags that highlight across the grey block, reading as a sweep of light. Animating `transform` (not `background-position`) keeps it on the GPU's compositor — cheap and smooth.
- **One keyframe, many blocks, in sync.** Every placeholder shares the same `.skel::after` animation with no negative delay, so all blocks shimmer in unison — the signature "skeleton" look.
- **Two stacked layers, one flag.** The real content sits in normal flow and *defines the card height*; the skeleton is an absolute overlay mirroring that layout. Toggling `is-loading` cross-fades between them, so there's **no layout shift** when content swaps in.
- **`ease-in-out`** on the sweep gives a slight accelerate/decelerate that feels more like a passing reflection than a constant scroll.

## Customization

- **Speed:** the sweep duration (`1.45s`) — slower (≈2s) feels calm/premium, faster (≈1s) feels busy. Keep all placeholders on the same value to stay synchronised.
- **Contrast:** the gap between the base grey (`#e6ddcd`) and the highlight alpha (`rgba(255,255,255,.92)`). On a dark theme, flip it: base `#1d222c`, highlight `rgba(255,255,255,.06)`.
- **Band shape:** the gradient stops (`18% / 50% / 82%`) set the highlight's width; the angle (`100deg`) sets its tilt. A narrower, more vertical band looks sharper.
- **Placeholder geometry:** match bar widths/heights to the real text (title ≈ 80%, subtitle ≈ 45%) so the skeleton previews the true layout.
- **Reveal:** tune the per-item `--i * 70ms` stagger and the image `scale(1.06)` settle for more or less drama.

## Accessibility & performance

- **Honour `prefers-reduced-motion`:** the CSS drops the sweep (static placeholder) and the JS refuses to start the auto-replay loop and shortens the delay — vestibular-safe.
- **Animate only `transform`/`opacity`.** Never animate `background-position`, `width`, or `box-shadow` for the shimmer — those trigger paint/layout every frame and tank performance with many cards.
- **Pause when idle:** `animation-play-state:paused` on hidden skeletons (here, once content is shown) stops the GPU doing invisible work.
- **Announce state:** mark the live region `role="status" aria-live="polite"` and set `aria-busy="true"` on the container while loading; flip it to `false` when done. Mark the decorative skeleton overlay `aria-hidden="true"`.
- **No layout shift (CLS):** because the real layer always reserves the space, the swap doesn't reflow the page — give the media a fixed `aspect-ratio` so the image slot is reserved too.

## Gotchas

- **Nothing to clip = no shimmer.** The `.skel` element needs `position:relative; overflow:hidden`, or the translating `::after` spills outside and the band never appears to travel *within* the block.
- **Don't shimmer forever.** A skeleton that never resolves is worse than a spinner — always tie removal of `is-loading` to the real fetch completing (or a timeout fallback), and avoid showing it for sub-200ms loads (it just flickers).
- **Keep the two layers the same size.** If the skeleton bars imply a taller card than the real content, you'll get a jump on swap. Give the body a `min-height` and clamp titles so both layers settle to the same height.
- **Out-of-sync sweeps look broken.** Adding random `animation-delay` per block scatters the highlight; keep them all identical for the cohesive look (a tiny shared delay between *rows* is the most you want).
- **Contrast trap:** too little difference between base and highlight and the shimmer is invisible on some displays; too much and it strobes. Aim for a gentle, clearly-visible band.
