---
name: skeleton-loader
description: Use when you want "Professional, practical, familiar" - Shows gray placeholder blocks while content loads.
---

# Skeleton Loader

> **Category:** Loading / Transition  -  **Personality:** Professional, practical, familiar
>
> **Best use:** Apps, dashboards, content feeds
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 1/5 - Visual impact 2/5 - Difficulty 2/5 - Performance cost 1/5

## What it is

A skeleton loader replaces "spinner-and-wait" with a gray wireframe of the content that is about to arrive: blocks where the image will be, bars where the headline and text will be, a circle where the avatar will be. A soft highlight sweeps across those blocks (the "shimmer") to signal that something is actively loading. Because the placeholders match the final layout's shape and size, the page does not jump when real data lands, and the wait *feels* shorter. It is the default loading pattern for content feeds, dashboards and app screens (Facebook, LinkedIn, YouTube all use it).

The demo on this page is a faked news feed ("Dispatch"). Each card holds **two layers** stacked in the same grid cell — a skeleton and the real article — and a `.is-loading` class on the card decides which one is visible. Press **Reload feed**, flip **Auto-loop**, or switch a section tab to replay the skeleton -> content transition on demand.

## Dependencies / CDN

None - pure CSS for the effect (the shimmer is an animated `background-position`; the swap is an `opacity` transition). Vanilla JS only orchestrates the fake "load" + replay. The demo loads two Google Fonts (optional):

```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=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600;6..72,700&display=swap" rel="stylesheet">
```

## HTML

Each card carries both layers. The skeleton mirrors the real layout's blocks so heights match and nothing shifts on reveal. Start with `is-loading` so the placeholder is what paints first.

```html
<article class="card is-loading">
  <!-- placeholder layer -->
  <div class="skel" aria-hidden="true">
    <span class="b b--thumb"></span>
    <div class="body">
      <span class="b b--pill"></span>
      <span class="b b--title"></span>
      <span class="b b--title" style="width:72%"></span>
      <span class="b b--line"></span>
      <span class="b b--line" style="width:54%"></span>
      <div class="meta"><span class="b b--circle"></span><span class="b b--name"></span></div>
    </div>
  </div>
  <!-- real content layer -->
  <div class="real">
    <div class="thumb"><img src="/img/interior-01.jpg" alt="Warm minimalist interior" decoding="async"></div>
    <div class="body">
      <span class="tag">Design</span>
      <h3 class="title">The quiet return of warm minimalism</h3>
      <p class="excerpt">Designers are trading stark white for clay, oat and oiled oak.</p>
      <div class="meta"><img class="avatar" src="/img/portrait-02.jpg" alt=""><span>Theo Brandt &middot; 5 min</span></div>
    </div>
  </div>
</article>
```

## CSS

```css
/* 1) STACK the two layers in one grid cell so the card height = the taller layer
      (the skeleton mirrors the content, so there is no jump on reveal). */
.card{display:grid; overflow:hidden; border-radius:13px}
.card > .skel, .card > .real{grid-area:1 / 1; min-width:0}

/* 2) CROSS-FADE driven by one class. .real is also nudged up a touch on reveal. */
.skel{opacity:0; transition:opacity .3s ease; pointer-events:none}
.real{opacity:1; transition:opacity .5s ease, transform .55s cubic-bezier(.2,.7,.2,1)}
.card.is-loading .skel{opacity:1}
.card.is-loading .real{opacity:0; transform:translateY(10px); pointer-events:none}

/* 3) THE SKELETON BLOCK + SHIMMER — the only lines that *are* the effect. */
.b{background:#e6e9f1; border-radius:7px; display:block}     /* flat gray when idle */
.card.is-loading .b{                                          /* animate only while loading */
  background:linear-gradient(100deg, #e6e9f1 30%, #f4f6fc 50%, #e6e9f1 70%);
  background-size:200% 100%;
  animation:shimmer 1.3s ease-in-out infinite;
}
@keyframes shimmer{0%{background-position:160% 0} 100%{background-position:-60% 0}}

/* block shapes — match them to the real elements they stand in for */
.b--thumb{width:100%; aspect-ratio:16/10}
.b--pill{width:62px; height:17px}
.b--title{width:100%; height:15px}
.b--line{width:100%; height:11px; border-radius:5px}
.b--circle{width:23px; height:23px; border-radius:50%}
.b--name{width:88px; height:11px; border-radius:5px}

/* the real thumbnail uses the SAME aspect-ratio, so the box reserves its height
   before the image bytes arrive — that is what kills the layout shift. */
.thumb{aspect-ratio:16/10; overflow:hidden; background:#eef1f6}
.thumb img{width:100%; height:100%; object-fit:cover; display:block}

/* respect users who opt out of motion: drop the sweep, keep the gray blocks */
@media (prefers-reduced-motion: reduce){ .card.is-loading .b{animation:none} }
```

## JavaScript

Not required for the effect (CSS does the shimmer + fade). JS only fakes the network round-trip so the demo is replayable: add `is-loading` to every card, then remove it after a delay, staggered for a "cards arriving" feel.

```js
var cards  = [].slice.call(document.querySelectorAll('.card'));
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
var timers = [];

function load(){
  timers.forEach(clearTimeout); timers = [];
  cards.forEach(function(c){ c.classList.add('is-loading'); });   // show skeletons

  var hold = reduce ? 600 : 1500, step = reduce ? 0 : 150;        // dwell + stagger
  cards.forEach(function(c, i){
    timers.push(setTimeout(function(){ c.classList.remove('is-loading'); }, hold + i * step));
  });
}

document.querySelector('#reload').addEventListener('click', load);
load();   // auto-play once so the loading state is visible on arrival
```

In a real app you would not use a timer at all — you add `is-loading` before `fetch()` and remove it in the `.then()` once data has rendered. Also flip `aria-busy` on the container (`true` while loading, `false` when done) and announce status via an `aria-live="polite"` region.

## How it works

- **Two layers, one cell.** Both the skeleton and the real content sit in the same CSS-grid cell (`grid-area:1/1`), so the card's height is the taller of the two. Because the skeleton mirrors the real layout, they are nearly identical — so when the swap happens, nothing reflows.
- **One class flips everything.** `.is-loading` raises the skeleton's opacity to 1 and drops the content's to 0; removing it reverses both. Two `opacity`/`transform` transitions = a smooth cross-fade, no JS animation.
- **The shimmer is an animated gradient.** A wide `linear-gradient` (gray -> light -> gray) is set to `background-size:200%` and its `background-position` is animated across the block — a moving highlight that reads as "working". It runs **only** while `.is-loading` is on the card, so idle cards cost nothing.
- **`aspect-ratio` reserves space.** Both the skeleton thumb and the real `<img>` use the same `aspect-ratio`, so the image box has its final height *before* the image downloads. That is the single most important detail for a jank-free reveal.

## Customization

- **Tone:** light UI uses `#e6e9f1` base / `#f4f6fc` highlight; on dark UI flip to e.g. base `#22262e` / highlight `#2e333d`.
- **Speed & feel:** the shimmer duration (`1.3s`) and easing tune the personality — slower + `ease-in-out` feels calm/premium, faster + `linear` feels snappy. Reverse the keyframe direction to sweep the other way.
- **No-shimmer variant:** for the most minimal look, skip the gradient entirely and just `animation: pulse 1.4s infinite` on `opacity` between `1` and `.55`.
- **Block radius:** larger `border-radius` on `.b` reads softer/friendlier; `0` reads more technical.
- **Stagger:** raise the per-card `step` for a more pronounced "feed filling in" effect, or set it to `0` to reveal everything at once.

## Accessibility & performance

- Mark every placeholder layer `aria-hidden="true"` so screen readers never read empty boxes; set `aria-busy="true"` on the region while loading and announce completion via an `aria-live="polite"` status node.
- Honour `prefers-reduced-motion`: drop the shimmer (and any auto-loop) and just show static gray blocks — the meaning survives without motion.
- Cheap by design: animating `background-position` is light, and it runs only on the brief loading window, then stops. For very long lists, prefer a single translated overlay (a `::after` sweeping via `transform: translateX()`, which is compositor-only) over hundreds of independently-shimmering nodes.
- Keep the shimmer subtle and the contrast between base/highlight modest; a harsh, fast sweep is visually noisy and can feel like a glitch.

## Gotchas

- **It must match the real layout.** If the skeleton's block sizes differ from the loaded content, the page jumps on reveal — which defeats the whole point. Size the placeholders to the content, and give image boxes the same `aspect-ratio`.
- **Don't show it for instant loads.** A skeleton that flashes for 80ms is worse than nothing. Add a small delay (e.g. only show it if the fetch takes >300ms) so fast responses skip it.
- **Don't skeleton forever.** Always have a timeout / error state; a skeleton that never resolves looks broken. (In this demo, content always resolves.)
- **`-webkit-line-clamp` needs `display:-webkit-box`** if you clamp the real title/excerpt to a fixed number of lines (used here to keep card heights predictable).
- **`background-position` animation repaints**, unlike `transform`. Fine for a handful of blocks over a short window; switch to a transform-based overlay if you are shimmering a huge grid continuously.
