ui-024

Skeleton Loader

Free

A skeleton loader: shimmering placeholders crossfade into real content with a stagger, and each card's height eases to what it loaded.

intermediate
4 more details
load-sequencestaggercrossfadeprogressive-enhancement

This element reads better at your own screen size than in the frame below:

Updated

About this component

A loading state that resolves instead of disappearing. Each card starts as a shimmering placeholder in the shape of the thing it is fetching, then the placeholder fades out, the card's height tweens to the height its real content actually needs, and the content rises into place. Cards resolve one after another so the panel settles rather than snapping.

Read the full effect overview

The height morph is measured, not guessed: the placeholder height and the content height are both read at reveal time, so a card whose copy runs long grows and a card whose copy runs short shrinks. The demo loads a panel of three stat tiles and a five-row list, which is the shape most feeds and dashboards actually wait on. One HTML file, one stylesheet, one script, GSAP core only, and two themes switched by a data-variant attribute.

Skeleton Loader - UI component preview

What's included

10 items
  • Placeholder blocks shimmer on background-position, so no CSS transform can collide with a GSAP tween
  • Card height tweens from the measured placeholder height to the measured content height
  • Content crossfades and rises while the height morph is still running
  • Cards resolve on a stagger so the panel settles instead of snapping
  • Border flashes the accent once as each card lands, read from a token rather than a literal
  • Optional photos fade in on their own load event, whatever order they arrive in
  • With JavaScript off the real content is simply the page, no placeholders and no blank state
  • Reduced motion switches states instantly instead of animating
  • window.skeletonReveal.reveal / reset for your own fetch code
  • Light and dark themes, every colour a custom property in both

Perfect for

4 use cases
  • Feeds, search results and dashboard lists that wait on an API
  • Article and product grids fetched after first paint
  • Infinite scroll pages that need a placeholder for the next batch
  • Any list currently replacing a spinner with a hard content pop

How it works

4 sections

The height morph is measured, not guessed

While the placeholder still owns the layout the script reads three numbers: the card's own height, the placeholder's height and the content's height. The target is the card height minus the placeholder height plus the content height, which cancels the card's padding out of the sum entirely, so no padding value is ever read or assumed. GSAP tweens height between those two numbers and the card ends on a cleared inline height, back to auto.

Crossfade, not swap

The content sits absolutely positioned at the top of the card while the placeholder is in flow, so both exist and both can be measured at once. The placeholder fades out, the content fades and rises in on an overlapping tween, and only when the card has landed does the content become a static in-flow block and the placeholder get display none.

The shimmer never fights GSAP

The sweep is a CSS animation on background-position across an oversized linear gradient, not a translated highlight element. Nothing in the stylesheet sets a transform on anything GSAP later animates, which is what keeps the height and opacity tweens starting from a real zero.

No JavaScript, no blank page

The placeholders are gated behind a has-js class set on the root element by one inline line, so a visitor without JavaScript gets the real content in flow and fully visible, and never sees a placeholder that will not resolve. Under prefers-reduced-motion every duration and the stagger collapse to zero, and the shimmer animation is switched off in CSS.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Skeleton Loader &mdash; GSAP Vault</title>
  <script>document.documentElement.className += ' has-js';</script>
  <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=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: 6px; border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    /* One row on a phone: the prompt goes so both control groups stay on screen. */
    @media (max-width: 560px) {
      .showcase-toolbar__prompt { display: none; }
      .showcase-toolbar { justify-content: flex-end; }
    }
  </style>
</head>
<body data-variant="light">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">The panel loads on its own. Press Reload to watch it resolve again.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Data">
      <button type="button" id="skReload">Reload</button>
    </div>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main class="stage">
    <div class="stage__inner">

      <section class="sk-feed" id="skFeed" aria-busy="true" aria-label="Account overview">

        <div class="sk-tiles">

          <article class="sk-card sk-card--tile">
            <div class="sk-skeleton" aria-hidden="true">
              <div class="sk-block sk-line sk-line-label"></div>
              <div class="sk-block sk-line sk-line-value"></div>
              <div class="sk-block sk-line sk-line-change"></div>
            </div>
            <div class="sk-content">
              <p class="sk-stat-label">Active seats</p>
              <p class="sk-stat-value">1,284</p>
              <p class="sk-stat-change">+62 this week</p>
            </div>
          </article>

          <article class="sk-card sk-card--tile">
            <div class="sk-skeleton" aria-hidden="true">
              <div class="sk-block sk-line sk-line-label"></div>
              <div class="sk-block sk-line sk-line-value"></div>
              <div class="sk-block sk-line sk-line-change"></div>
            </div>
            <div class="sk-content">
              <p class="sk-stat-label">Weekly sessions</p>
              <p class="sk-stat-value">18,430</p>
              <p class="sk-stat-change">+4.1% on last week</p>
            </div>
          </article>

          <article class="sk-card sk-card--tile">
            <div class="sk-skeleton" aria-hidden="true">
              <div class="sk-block sk-line sk-line-label"></div>
              <div class="sk-block sk-line sk-line-value"></div>
              <div class="sk-block sk-line sk-line-change"></div>
            </div>
            <div class="sk-content">
              <p class="sk-stat-label">Open tickets</p>
              <p class="sk-stat-value">27</p>
              <p class="sk-stat-change">5 fewer than yesterday</p>
            </div>
          </article>

        </div>

        <div class="sk-list">

          <article class="sk-card sk-card--row">
            <div class="sk-skeleton sk-row-skeleton" aria-hidden="true">
              <div class="sk-block sk-avatar-block"></div>
              <div class="sk-lines">
                <div class="sk-block sk-line sk-line-title"></div>
                <div class="sk-block sk-line sk-line-meta"></div>
              </div>
            </div>
            <div class="sk-content sk-row-content">
              <span class="sk-avatar" aria-hidden="true">NT</span>
              <div class="sk-text">
                <p class="sk-title">Northwind Traders</p>
                <p class="sk-meta">Scale &middot; EMEA &middot; 128 seats &middot; updated 2d ago</p>
              </div>
            </div>
          </article>

          <article class="sk-card sk-card--row">
            <div class="sk-skeleton sk-row-skeleton" aria-hidden="true">
              <div class="sk-block sk-avatar-block"></div>
              <div class="sk-lines">
                <div class="sk-block sk-line sk-line-title"></div>
                <div class="sk-block sk-line sk-line-meta"></div>
              </div>
            </div>
            <div class="sk-content sk-row-content">
              <span class="sk-avatar" aria-hidden="true">AF</span>
              <div class="sk-text">
                <p class="sk-title">Alpine Freight</p>
                <p class="sk-meta">Growth &middot; EMEA &middot; 42 seats &middot; updated 9d ago</p>
              </div>
            </div>
          </article>

          <article class="sk-card sk-card--row">
            <div class="sk-skeleton sk-row-skeleton" aria-hidden="true">
              <div class="sk-block sk-avatar-block"></div>
              <div class="sk-lines">
                <div class="sk-block sk-line sk-line-title"></div>
                <div class="sk-block sk-line sk-line-meta"></div>
              </div>
            </div>
            <div class="sk-content sk-row-content">
              <span class="sk-avatar" aria-hidden="true">SC</span>
              <div class="sk-text">
                <p class="sk-title">Sable and Co</p>
                <p class="sk-meta">Scale &middot; AMER &middot; 96 seats &middot; updated 4d ago</p>
              </div>
            </div>
          </article>

          <article class="sk-card sk-card--row">
            <div class="sk-skeleton sk-row-skeleton" aria-hidden="true">
              <div class="sk-block sk-avatar-block"></div>
              <div class="sk-lines">
                <div class="sk-block sk-line sk-line-title"></div>
                <div class="sk-block sk-line sk-line-meta"></div>
              </div>
            </div>
            <div class="sk-content sk-row-content">
              <span class="sk-avatar" aria-hidden="true">KL</span>
              <div class="sk-text">
                <p class="sk-title">Kestrel Labs</p>
                <p class="sk-meta">Starter &middot; APAC &middot; 12 seats &middot; updated 21d ago</p>
              </div>
            </div>
          </article>

          <article class="sk-card sk-card--row">
            <div class="sk-skeleton sk-row-skeleton" aria-hidden="true">
              <div class="sk-block sk-avatar-block"></div>
              <div class="sk-lines">
                <div class="sk-block sk-line sk-line-title"></div>
                <div class="sk-block sk-line sk-line-meta"></div>
              </div>
            </div>
            <div class="sk-content sk-row-content">
              <span class="sk-avatar" aria-hidden="true">IH</span>
              <div class="sk-text">
                <p class="sk-title">Ironbark Health</p>
                <p class="sk-meta">Growth &middot; AMER &middot; 64 seats &middot; updated 1d ago</p>
              </div>
            </div>
          </article>

        </div>

      </section>

      <p class="sk-status" id="skStatus" role="status" aria-live="polite"></p>

    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="assets/script.js"></script>

  <script>
    // Demo-only: switches data-variant on <body>; also honours ?variant=name.
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });
    })();
  </script>
</body>
</html>
/**
 * Skeleton to Content Reveal
 *
 * Shimmering placeholder cards that crossfade into their real content with a
 * stagger. Each card's height is measured and tweened from the placeholder
 * height to the content height, and photos fade in on their own load event.
 *
 * @plugins none (core GSAP only)
 */

(function () {
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initSkeletonToContentReveal() {
    if (typeof gsap === 'undefined') {
        console.warn('[skeleton-to-content-reveal] GSAP is required but was not found.');
        return;
    }

    var feed = document.getElementById('skFeed');
    var statusEl = document.getElementById('skStatus');
    var loadBtn = document.getElementById('skLoad');
    var reloadBtn = document.getElementById('skReload');
    if (!feed) return;

    var cards = Array.prototype.slice.call(feed.querySelectorAll('.sk-card'));
    if (!cards.length) return;

    /* How long the fake request runs before the reveal starts on its own.
       Wire this to a real fetch and call skeletonReveal.reveal() from its
       resolve handler instead. */
    var AUTO_SECONDS = 0.6;
    /* Gap between one card's reveal and the next. */
    var STAGGER = 0.09;

    var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    /* One duration multiplier: under reduced motion every tween is instant
       and the states still occur in order, so nothing is skipped. */
    var d = reduced ? 0 : 1;

    var revealed = false;
    var running = false;
    var autoTimer = null;
    var master = null;

    /* Declared before first use: build:demos un-hoists function declarations. */
    function token(name) {
        return getComputedStyle(document.body).getPropertyValue(name).trim();
    }

    function setStatus(text) {
        if (statusEl) statusEl.textContent = text;
    }

    function partsOf(card) {
        return {
            skeleton: card.querySelector('.sk-skeleton'),
            content: card.querySelector('.sk-content'),
            photo: card.querySelector('.sk-photo')
        };
    }

    function whenPhotoReady(photo, done) {
        if (!photo) { done(); return; }
        if (photo.complete && photo.naturalWidth > 0) { done(); return; }
        photo.addEventListener('load', done, { once: true });
        photo.addEventListener('error', done, { once: true });
    }

    function settle(card) {
        var p = partsOf(card);
        p.skeleton.style.display = 'none';
        p.content.classList.add('is-live');
        gsap.set(card, { clearProps: 'height,borderColor' });
        gsap.set(p.content, { clearProps: 'opacity,y' });
        if (p.photo) gsap.set(p.photo, { clearProps: 'opacity' });
    }

    function toSkeleton() {
        if (master) master.kill();
        if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
        cards.forEach(function (card) {
            var p = partsOf(card);
            p.content.classList.remove('is-live');
            p.skeleton.style.display = '';
            gsap.set(card, { clearProps: 'height,borderColor' });
            gsap.set(p.skeleton, { opacity: 1 });
            gsap.set(p.content, { opacity: 0, y: 10 });
            if (p.photo) gsap.set(p.photo, { opacity: 0 });
        });
        revealed = false;
        running = false;
        feed.setAttribute('aria-busy', 'true');
        setStatus('Loading ' + cards.length + ' items…');
    }

    function cardTimeline(card) {
        var p = partsOf(card);
        /* Measure both heights while the skeleton still owns the layout. The
           card's own padding cancels out of the difference, so no padding is
           read or assumed anywhere. */
        var startH = card.getBoundingClientRect().height;
        var skH = p.skeleton.getBoundingClientRect().height;
        var ctH = p.content.getBoundingClientRect().height;
        var targetH = startH - skH + ctH;

        var tl = gsap.timeline();
        tl.set(card, { height: startH })
          .to(p.skeleton, { opacity: 0, duration: 0.28 * d, ease: 'power1.out' })
          .to(card, { height: targetH, duration: 0.5 * d, ease: 'power3.inOut' }, '<')
          /* The flash reads the accent from the variant, never a literal. */
          .to(card, { borderColor: token('--card-flash'), duration: 0.18 * d }, '<')
          .to(p.content, { opacity: 1, y: 0, duration: 0.4 * d, ease: 'power2.out' }, '<0.12')
          .to(card, { borderColor: token('--card-line'), duration: 0.5 * d }, '<')
          .add(function () {
              /* A card need not have a photo; this demo's rows use initials. */
              if (!p.photo) return;
              whenPhotoReady(p.photo, function () {
                  gsap.to(p.photo, { opacity: 1, duration: 0.45 * d, ease: 'power1.out' });
              });
          }, '<')
          .add(function () { settle(card); });
        return tl;
    }

    function reveal() {
        if (revealed || running) return;
        if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
        running = true;
        if (loadBtn) loadBtn.disabled = true;

        master = gsap.timeline({
            onComplete: function () {
                running = false;
                revealed = true;
                feed.setAttribute('aria-busy', 'false');
                setStatus(cards.length + ' items loaded.');
            }
        });
        cards.forEach(function (card, i) {
            master.add(cardTimeline(card), i * STAGGER * (reduced ? 0 : 1));
        });
    }

    function revealInstantly(card) {
        var p = partsOf(card);
        gsap.set(p.skeleton, { opacity: 0 });
        gsap.set(p.content, { opacity: 1, y: 0 });
        if (p.photo) gsap.set(p.photo, { opacity: 1 });
        settle(card);
    }

    function restart() {
        toSkeleton();
        if (loadBtn) loadBtn.disabled = false;
        autoTimer = setTimeout(reveal, AUTO_SECONDS * 1000);
    }

    if (loadBtn) loadBtn.addEventListener('click', reveal);
    if (reloadBtn) reloadBtn.addEventListener('click', restart);

    /* A resting page of skeletons says nothing on a thumbnail, so show the
       moment the component exists for: the front of the panel resolved, the
       rest of it still loading. */
    window.__thumbnail = function () {
        if (master) master.kill();
        if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
        cards.slice(0, 4).forEach(revealInstantly);
        setStatus('Loading ' + cards.length + ' items…');
    };

    /* Small public API so a real fetch can drive the same states. */
    window.skeletonReveal = { reveal: reveal, reset: restart };

    restart();
});
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){var e=document.getElementById("skFeed"),t=document.getElementById("skStatus"),o=document.getElementById("skLoad"),n=document.getElementById("skReload");if(e){var a=Array.prototype.slice.call(e.querySelectorAll(".sk-card"));if(a.length){var i=window.matchMedia("(prefers-reduced-motion: reduce)").matches,r=i?0:1,s=!1,l=!1,c=null,d=null;o&&o.addEventListener("click",y),n&&n.addEventListener("click",m),window.__thumbnail=function(){d&&d.kill(),c&&(clearTimeout(c),c=null),a.slice(0,4).forEach(f),p("Loading "+a.length+" items…")},window.skeletonReveal={reveal:y,reset:m},m()}}}else console.warn("[skeleton-to-content-reveal] GSAP is required but was not found.");function u(e){return getComputedStyle(document.body).getPropertyValue(e).trim()}function p(e){t&&(t.textContent=e)}function g(e){return{skeleton:e.querySelector(".sk-skeleton"),content:e.querySelector(".sk-content"),photo:e.querySelector(".sk-photo")}}function h(e){var t=g(e);t.skeleton.style.display="none",t.content.classList.add("is-live"),gsap.set(e,{clearProps:"height,borderColor"}),gsap.set(t.content,{clearProps:"opacity,y"}),t.photo&&gsap.set(t.photo,{clearProps:"opacity"})}function y(){s||l||(c&&(clearTimeout(c),c=null),l=!0,o&&(o.disabled=!0),d=gsap.timeline({onComplete:function(){l=!1,s=!0,e.setAttribute("aria-busy","false"),p(a.length+" items loaded.")}}),a.forEach(function(e,t){d.add(function(e){var t=g(e),o=e.getBoundingClientRect().height,n=o-t.skeleton.getBoundingClientRect().height+t.content.getBoundingClientRect().height,a=gsap.timeline();return a.set(e,{height:o}).to(t.skeleton,{opacity:0,duration:.28*r,ease:"power1.out"}).to(e,{height:n,duration:.5*r,ease:"power3.inOut"},"<").to(e,{borderColor:u("--card-flash"),duration:.18*r},"<").to(t.content,{opacity:1,y:0,duration:.4*r,ease:"power2.out"},"<0.12").to(e,{borderColor:u("--card-line"),duration:.5*r},"<").add(function(){var e,o;t.photo&&(o=function(){gsap.to(t.photo,{opacity:1,duration:.45*r,ease:"power1.out"})},(e=t.photo)?e.complete&&e.naturalWidth>0?o():(e.addEventListener("load",o,{once:!0}),e.addEventListener("error",o,{once:!0})):o())},"<").add(function(){h(e)}),a}(e),.09*t*(i?0:1))}))}function f(e){var t=g(e);gsap.set(t.skeleton,{opacity:0}),gsap.set(t.content,{opacity:1,y:0}),t.photo&&gsap.set(t.photo,{opacity:1}),h(e)}function m(){d&&d.kill(),c&&(clearTimeout(c),c=null),a.forEach(function(e){var t=g(e);t.content.classList.remove("is-live"),t.skeleton.style.display="",gsap.set(e,{clearProps:"height,borderColor"}),gsap.set(t.skeleton,{opacity:1}),gsap.set(t.content,{opacity:0,y:10}),t.photo&&gsap.set(t.photo,{opacity:0})}),s=!1,l=!1,e.setAttribute("aria-busy","true"),p("Loading "+a.length+" items…"),o&&(o.disabled=!1),c=setTimeout(y,600)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }

/* ---------- Theme tokens ----------
   Every colour, radius and shadow the component uses is a custom property on
   <body>, keyed by data-variant. Two values of one design: `light` and
   `dark`. Nothing in assets/script.js reads the theme name; it only reads two
   colour tokens (--card-flash and --card-line) with getComputedStyle at the
   moment the landing flash starts, so re-valuing a block is the whole of a
   rebrand. */
body,
body[data-variant="light"] {
  color-scheme: light;

  --ground: #f2f2f1;          /* the page: grey paper, not white */
  --ground-2: #e9e9e7;        /* the demo toolbar strip */
  --raised: #fbfbfa;          /* tiles and rows */
  --hover: #e2e2df;
  --ink: #141412;
  --ink-2: #4b4b47;           /* row titles, values */
  --ink-3: #676762;           /* labels, meta, the change line */
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);

  --accent: #00b8a9;
  --accent-ink: #10201e;      /* ink on teal: white on it is only 2.3:1 */

  --shadow: 0 1px 2px rgba(20, 20, 18, 0.05), 0 3px 8px rgba(20, 20, 18, 0.05);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;

  /* the card the component draws */
  --card-bg: var(--raised);
  --card-line: rgba(20, 20, 18, 0.12);
  --card-flash: #00b8a9;      /* flashed once as a card lands; the accent */
  --card-radius: 10px;
  --card-pad: 14px;

  /* the placeholder: two greys off the same ground scale, nothing louder */
  --sk-block: #e6e6e3;
  --sk-shine: #f0f0ed;
  --sk-radius: 4px;

  /* the avatar chip on a resolved row */
  --avatar-bg: #e4e4e1;
  --avatar-fg: #141412;
}

body[data-variant="dark"] {
  color-scheme: dark;

  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);

  --accent: #00b8a9;
  --accent-ink: #10201e;

  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 18px rgba(0, 0, 0, 0.4);

  --card-bg: var(--raised);
  --card-line: rgba(241, 239, 234, 0.12);
  --card-flash: #00b8a9;

  --sk-block: #242424;
  --sk-shine: #2e2e2e;

  --avatar-bg: #2c2c2c;
  --avatar-fg: #f1efea;
}

::selection { background: var(--accent); color: var(--accent-ink); }

/* ---------- The component ---------- */
.sk-feed {
  display: flex;
  flex-direction: column;
  gap: 20px;
  width: 100%;
  font-family: var(--font);
  color: var(--ink);
}

.sk-tiles {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  /* start, not stretch: a stretched tile is taller than its own placeholder,
     and the height morph measures the card it is given. */
  align-items: start;
}

.sk-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.sk-card {
  position: relative;
  overflow: hidden;
  padding: var(--card-pad);
  background: var(--card-bg);
  border: 1px solid var(--card-line);
  border-radius: var(--card-radius);
  box-shadow: var(--shadow);
}

.sk-card--tile { --card-pad: 16px; }

/* ---------- Placeholders ---------- */
/* The sweep runs on background-position, never a transform, so nothing here
   can collide with a GSAP transform later. */
.sk-skeleton { display: flex; flex-direction: column; gap: 10px; }
.sk-block {
  background-color: var(--sk-block);
  background-image: linear-gradient(90deg, var(--sk-block) 0%, var(--sk-shine) 50%, var(--sk-block) 100%);
  background-size: 300% 100%;
  border-radius: var(--sk-radius);
  animation: sk-shimmer 1.6s linear infinite;
}
@keyframes sk-shimmer {
  from { background-position: 150% 0; }
  to { background-position: -150% 0; }
}

.sk-line { height: 10px; }
.sk-line-label { width: 58%; }
.sk-line-value { height: 22px; width: 44%; }
.sk-line-change { width: 72%; }

.sk-row-skeleton { flex-direction: row; align-items: center; gap: 12px; }
.sk-avatar-block { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 999px; }
.sk-lines { flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px; }
.sk-line-title { width: 42%; height: 11px; }
.sk-line-meta { width: 62%; }

/* ---------- Real content ---------- */
/* Pre-hidden only when JavaScript is present. With JS off the placeholders are
   never shown and this block is the page, in flow, fully visible. */
.sk-skeleton { display: none; }
.has-js .sk-skeleton { display: flex; }
.has-js .sk-content { position: absolute; left: var(--card-pad); right: var(--card-pad); top: var(--card-pad); opacity: 0; }
.has-js .sk-content.is-live { position: static; opacity: 1; }

.sk-content { display: flex; flex-direction: column; gap: 6px; }

/* a stat tile */
.sk-stat-label { font-size: 0.75rem; color: var(--ink-2); }
.sk-stat-value {
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}
.sk-stat-change { font-size: 0.75rem; color: var(--ink-2); font-variant-numeric: tabular-nums; }

/* a list row */
.sk-row-content { flex-direction: row; align-items: center; gap: 12px; }
.sk-avatar {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  border-radius: 999px;
  background: var(--avatar-bg);
  color: var(--avatar-fg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}
.sk-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.sk-title { font-size: 0.875rem; font-weight: 500; color: var(--ink); line-height: 1.3; }
.sk-meta { font-size: 0.75rem; color: var(--ink-2); line-height: 1.3; font-variant-numeric: tabular-nums; }

.sk-status {
  min-height: 1.2em;
  font-size: 0.75rem;
  color: var(--ink-3);
  padding-top: 12px;
}

@media (max-width: 560px) {
  .sk-tiles { grid-template-columns: minmax(0, 1fr); }
  .sk-stat-value { font-size: 1.375rem; }
}

@media (prefers-reduced-motion: reduce) {
  .sk-block { animation: none; background-image: none; }
}

/* ---- demo furniture: the ground the component sits on ----------------
   `.stage` is the demo page only, never part of what a buyer drops in.
   assets/script.js reads none of it. */
html { overflow-x: clip; }
body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px; /* the demo toolbar strip */
}
.stage { min-height: calc(100svh - 44px); display: grid; grid-template-columns: minmax(0, 1fr); place-items: center; padding: 32px 20px; }
.stage__inner { width: 100%; max-width: 720px; }

@media (max-width: 720px) { .stage { padding: 16px 12px; } }
@media (prefers-reduced-motion: reduce) { .stage * { transition: none !important; } }

Shimmering placeholders that crossfade into real content with a stagger, each card's height tweening from the placeholder shape to the content it loaded.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, every colour and size a plain CSS custom property

  • assets/script.js: readable, commented source with an onReady guard

  • Placeholder blocks shimmer on background-position, so no CSS transform can collide with a GSAP tween

  • Card height tweens from the measured placeholder height to the measured content height

  • Content crossfades and rises while the height morph is still running

  • Cards resolve on a stagger so the panel settles instead of snapping

  • The border flashes the accent once as each card lands, read from a token, never a literal

  • Optional .sk-photo images fade in on their own load event, whatever order they arrive in

  • With JavaScript off the real content is simply the page, no placeholders and no blank state

  • window.skeletonReveal.reveal / reset for your own fetch code

Quick Start

1. Add to your HTML <head>:

<script>document.documentElement.className += ' has-js';</script>
<link rel="stylesheet" href="assets/style.css">

That one inline line is what keeps a no-JavaScript visitor out of a permanent loading state: every pre-hidden rule in the stylesheet is scoped to .has-js.

2. Add to your <body>:

Copy the <section class="sk-feed"> block out of index.html. Each card is one <article class="sk-card"> holding a .sk-skeleton and a .sk-content. Add or remove cards freely, group them however your layout wants (the demo has a row of three tiles and a list of five rows); the script counts every .sk-card inside the feed and staggers whatever it finds. The only id it needs is skFeed, plus skStatus if you want the live region.

3. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

4. Drive it from your own fetch:

fetch('/api/accounts')
  .then(function (r) { return r.json(); })
  .then(function (data) {
    paintCards(data);                 // your own render into the .sk-content blocks
    window.skeletonReveal.reveal();   // then resolve the placeholders
  });

// back to placeholders, for a refresh or a new query
window.skeletonReveal.reset();

Set AUTO_SECONDS in assets/script.js to Infinity once your fetch owns the timing, or leave it as a safety net. The demo's Reload control is wired to nothing but window.skeletonReveal.reset().

Using It With Your Own Design

What the component actually requires:

  • A card element with position: relative and overflow: hidden, holding exactly one .sk-skeleton and one .sk-content.
  • The .has-js gate. Without it, no-JavaScript visitors see placeholders that never resolve.
  • The .has-js .sk-content rule that positions the content absolutely, and the .is-live rule that returns it to the flow. The script only toggles that class; the positioning is CSS.
  • Nothing in your CSS may set a transform on the card, the content or an optional .sk-photo. GSAP animates height, opacity and y on those, and a CSS transform is parsed into the same values and fights them.
  • If a card sits in a grid, give the track align-items: start. A stretched card is taller than its own placeholder, and the height morph measures the card it is given.

Demo furniture. The toolbar strip across the top of index.html (the prompt, the Reload button and the Light/Dark toggle) and the .stage / .stage__inner wrapper that centres the panel on the grey ground are the demo page only. None of it ships in what you drop in; delete both and put .sk-feed wherever your own layout wants it.

Everything else in the demo is dressing you can replace: the stat tiles, the avatar initials, the meta line. The placeholder blocks are just divs with .sk-block, so make them any shape you like, because the height morph measures whatever you build rather than assuming a size.

How It Works

The height morph is measured, not guessed. While the placeholder still owns the layout the script reads three numbers: the card's own height, the placeholder's height and the content's height. The target is cardHeight - skeletonHeight + contentHeight, which cancels the card's padding out of the sum, so no padding value is ever read or assumed. GSAP tweens height between those two numbers and the card ends on a cleared inline height, back to auto.

Crossfade, not swap. The content sits absolutely positioned at the top of the card while the placeholder is in flow, so both exist and both can be measured at once. The placeholder fades out, the content fades and rises in on an overlapping tween, and only when the card has landed does the content become a static in-flow block and the placeholder get display: none.

The shimmer never fights GSAP. The sweep is a CSS animation on background-position across an oversized linear gradient, not a translated highlight element. Nothing in the stylesheet sets a transform on anything GSAP later animates.

Photos on their own clock. A card may carry an img.sk-photo; if it does, it starts at zero opacity and is faded in from its own load event, or immediately if the browser already has it cached. A slow image therefore delays only itself, never the card it sits in. A card without one is left alone.

Themes

The component ships in two themes, light and dark, which are one design at two token values. Pick one by setting the attribute on <body> in the demo, or on the component's own root in your page:

<body data-variant="dark">

Nothing else changes: same markup, same script, same timings. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute.

Every colour, radius and shadow the component uses is a custom property defined in both body[data-variant="light"] and body[data-variant="dark"] in assets/style.css:

--ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --shadow, --font, --radius, --radius-sm, --card-bg, --card-line, --card-flash, --card-radius, --card-pad, --sk-block, --sk-shine, --sk-radius, --avatar-bg, --avatar-fg.

To wear your own brand, re-value that list in both blocks; there is no selector to override. The script reads --card-flash and --card-line with getComputedStyle at the moment the landing flash starts, so a theme swap mid-demo is correct on the very next reveal, and no theme name is ever read in JavaScript.

Customisation

Property Where Default (light) Description
--card-bg style.css #fbfbfa Card ground
--card-line style.css rgba(20, 20, 18, 0.12) Card border, and the colour the flash returns to
--card-flash style.css #00b8a9 Border colour flashed once as a card lands
--card-radius style.css 10px Card corner radius
--card-pad style.css 14px Card padding, and the inset the absolute content uses
--sk-block style.css #e6e6e3 Placeholder block base colour
--sk-shine style.css #f0f0ed The lighter band that sweeps across it
--sk-radius style.css 4px Placeholder block corner radius
--avatar-bg / --avatar-fg style.css #e4e4e1 / #141412 The initials chip on a resolved row
AUTO_SECONDS script.js 0.6 Delay before the reveal starts on its own
STAGGER script.js 0.09 Gap between one card resolving and the next

The shimmer speed is the 1.6s in the .sk-block animation shorthand. Easings and durations are the gsap.to calls in cardTimeline.

Keyboard and ARIA contract

The component is a loading state, not a control: it takes no focus and has no keys of its own, so it never interrupts the tab order of the page it lands in.

  • .sk-feed carries aria-busy="true" while placeholders are showing and aria-busy="false" once the content has landed, and an aria-label naming what is loading.
  • A role="status" aria-live="polite" line (#skStatus) announces "Loading 8 items…" and then "8 items loaded.", so the change is spoken rather than only seen.
  • Placeholders are aria-hidden="true", so a screen reader is never read a list of empty boxes.
  • Decorative avatar initials are aria-hidden="true"; the account name beside them is the real text.
  • Under prefers-reduced-motion: reduce every duration and the stagger collapse to zero and the shimmer animation is switched off, so the content simply appears.
  • With JavaScript off the real content is in the flow and fully visible; the placeholders are never shown at all.
  • The demo's toolbar buttons are real buttons with visible :focus-visible outlines in the accent.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework