← Feedback

Skeleton to Content

Shimmering grey skeleton bars hold the shape of a card while it loads. When the content arrives each bar stretches and reshapes to fit the line of text or the picture it stood in for, then melts away as the real thing fades up underneath, top to bottom.

Maya Okafor

Product designer · Lisbon

Shipped the new sign-up flow today. Three screens fewer and a lot friendlier.

How to use it

  1. Paste the snippet just before </head>.
  2. Put your real content in a box and add data-skeleton-to-content to the box. Headings, paragraphs, list items, images and buttons each get skeleton bars, one per line of text. Add data-skeleton to anything else that should get a block, such as an avatar.
  3. Add data-manual and call animacious.skeletonToContent(el) once your data is in. Without it the content arrives after data-delay.

Options

Add any of these attributes to the same element.

data-delayHow long it shows the skeleton before the content arrives. Default 1600 ms.
data-manualStay as a skeleton until animacious.skeletonToContent(el).
data-itemsYour own CSS selector for what gets skeleton bars.
data-colorColour of the bars. Default #e4e1ec.
data-loopGo back to the skeleton after a pause and load again.
data-holdHow long the content shows before looping. Default 2600 ms.

The box has aria-busy while loading, and the bars are hidden from screen readers. The bars follow the box if it changes size while loading. With reduced motion the content simply shows.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Skeleton to Content: the real content sits hidden under shimmering grey
    // skeleton bars while it "loads". When it arrives, each bar stretches and
    // reshapes to fit the words or image it stood in for, then melts away as
    // the content fades up underneath, top to bottom.
    // Usage: <div data-skeleton-to-content> …your real content… </div>
    // Headings, paragraphs, list items, images, svgs and buttons get bars of
    // their own (one per line of text); add data-skeleton to any other block,
    // such as an avatar, to give it one too.
    // Optional: data-delay (ms of loading before the content arrives, default
    // 1600), data-manual (stay loading until a script call), data-items (your
    // own CSS selector for what gets a bar), data-color (bar colour, default
    // #e4e1ec), data-loop (go back to the skeleton after a pause and load
    // again), data-hold (ms the content shows before looping, default 2600).
    // Script use: animacious.skeletonToContent(el) when the content is in;
    // animacious.skeletonToContent(el, false) shows the skeleton again.
    var DEFAULTS = { delay: 1600, items: 'img,svg,video,canvas,h1,h2,h3,h4,h5,h6,p,li,button,[data-skeleton]', color: '#e4e1ec', hold: 2600 };
    var BLOCKS = /^(IMG|SVG|VIDEO|CANVAS|BUTTON)$/i;

    var css =
      '.skc-host{position:relative}' +
      '.skc-layer{position:absolute;inset:0;pointer-events:none;overflow:hidden;border-radius:inherit}' +
      '.skc-bar{position:absolute;overflow:hidden;background:var(--skc-c)}' +
      '.skc-bar::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.6),transparent);' +
      'transform:translateX(-100%);animation:skc-shine 1.5s ease-in-out infinite;animation-delay:var(--skc-d,0s)}' +
      '@keyframes skc-shine{60%,100%{transform:translateX(100%)}}' +
      '@media (prefers-reduced-motion: reduce){.skc-bar::after{animation:none;opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function opt(el, k) { var v = el.dataset[k]; return v === undefined ? DEFAULTS[k] : v; }

    // What gets a bar: matching elements, but not ones inside another match.
    function items(el) {
      var all;
      try { all = Array.prototype.slice.call(el.querySelectorAll(opt(el, 'items'))); } catch (err) { all = []; }
      return all.filter(function (it) {
        for (var p = it.parentElement; p && p !== el; p = p.parentElement) if (all.indexOf(p) >= 0) return false;
        return true;
      });
    }

    // Where each item's bars end up (the real shapes), relative to el.
    function shapes(el, it) {
      var box = el.getBoundingClientRect();
      var ox = box.left + el.clientLeft, oy = box.top + el.clientTop;
      var cs = getComputedStyle(it);
      var r = it.getBoundingClientRect();
      if (BLOCKS.test(it.tagName) || it.hasAttribute('data-skeleton') || !it.textContent.trim()) {
        return [{ x: r.left - ox, y: r.top - oy, w: r.width, h: r.height, radius: cs.borderRadius === '0px' ? '6px' : cs.borderRadius, block: true, full: r.width }];
      }
      // Text: one bar per line, from the line boxes of its contents.
      var range = document.createRange();
      range.selectNodeContents(it);
      var lines = [];
      Array.prototype.forEach.call(range.getClientRects(), function (q) {
        if (q.width < 1 || q.height < 1) return;
        var line = null;
        for (var i = 0; i < lines.length; i++) if (Math.abs(lines[i].top - q.top) < q.height / 2) line = lines[i];
        if (!line) lines.push({ top: q.top, bottom: q.bottom, left: q.left, right: q.right });
        else { line.left = Math.min(line.left, q.left); line.right = Math.max(line.right, q.right); line.bottom = Math.max(line.bottom, q.bottom); }
      });
      var lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.3;
      var padL = parseFloat(cs.paddingLeft) || 0, padR = parseFloat(cs.paddingRight) || 0;
      return lines.map(function (l) {
        var h = Math.min(lh, l.bottom - l.top);
        return { x: l.left - ox, y: (l.top + l.bottom) / 2 - h / 2 - oy, w: l.right - l.left, h: h, radius: '4px', block: false, full: r.width - padL - padR };
      });
    }

    // The skeleton look of a shape: text lines become rounded bars of a made-up length.
    function skeleton(sh, last, seed) {
      if (sh.block) return { left: sh.x + 'px', top: sh.y + 'px', width: sh.w + 'px', height: sh.h + 'px', borderRadius: sh.radius };
      var w = last ? sh.full * (0.45 + 0.4 * seed) : sh.full;
      var h = Math.max(6, sh.h * 0.58);
      return { left: sh.x + 'px', top: sh.y + (sh.h - h) / 2 + 'px', width: Math.max(24, Math.min(sh.full, w)) + 'px', height: h + 'px', borderRadius: '99px' };
    }

    function build(el) {
      var s = el._skc;
      s.layer.textContent = '';
      s.groups = [];
      var W = el.clientWidth || 1;
      s.items.forEach(function (it, n) {
        var list = shapes(el, it);
        var bars = list.map(function (sh, i) {
          var bar = document.createElement('span');
          bar.className = 'skc-bar';
          var seed = ((n * 7 + i * 3) % 10) / 10;
          var look = skeleton(sh, i === list.length - 1, seed);
          for (var k in look) bar.style[k] = look[k];
          bar.style.setProperty('--skc-d', (sh.x / W * 0.35).toFixed(2) + 's');
          s.layer.appendChild(bar);
          return { bar: bar, sh: sh };
        });
        s.groups.push({ it: it, bars: bars });
      });
    }

    function loading(el, instant) {
      var s = el._skc;
      s.loaded = false;
      el.setAttribute('aria-busy', 'true');
      s.layer.style.display = '';
      build(el);
      s.items.forEach(function (it) { anime.utils.set(it, { opacity: 0, filter: 'blur(0px)' }); });
      if (!instant) anime.animate(s.layer.children, { opacity: [0, 1], duration: 400, ease: 'out(2)' });
    }

    function arrive(el) {
      var s = el._skc;
      if (s.loaded) return;
      s.loaded = true;
      el.removeAttribute('aria-busy');
      build(el);
      s.groups.forEach(function (g, n) {
        var delay = n * 90;
        g.bars.forEach(function (b, i) {
          var sh = b.sh, d = delay + i * 40;
          // Reshape to the real thing, then fade away.
          anime.animate(b.bar, { left: sh.x + 'px', top: sh.y + 'px', width: sh.w + 'px', height: sh.h + 'px', borderRadius: sh.radius, duration: 480, delay: d, ease: 'inOut(3)' });
          anime.animate(b.bar, { opacity: 0, duration: 380, delay: d + 380, ease: 'out(2)' });
        });
        anime.animate(g.it, { opacity: [0, 1], filter: ['blur(6px)', 'blur(0px)'], duration: 520, delay: delay + 300, ease: 'out(3)' });
      });
      clearTimeout(s.hideTimer);
      s.hideTimer = setTimeout(function () { if (s.loaded) s.layer.style.display = 'none'; }, s.groups.length * 90 + 1000);
    }

    function cycle(el) {
      var s = el._skc, d = el.dataset;
      clearTimeout(s.timer);
      if (!s.visible) { s.waiting = true; return; }
      s.waiting = false;
      if (s.loaded) loading(el);
      if (d.manual !== undefined) return;
      s.timer = setTimeout(function () {
        arrive(el);
        if (d.loop === undefined || d.loop === 'false') return;
        s.timer = setTimeout(function () {
          anime.animate(s.items, { opacity: 0, duration: 350, ease: 'in(2)', onComplete: function () { if (s.loaded) cycle(el); } });
        }, s.groups.length * 90 + 900 + +opt(el, 'hold'));
      }, +opt(el, 'delay'));
    }

    function setup(el) {
      if (getComputedStyle(el).position === 'static') el.classList.add('skc-host');
      el.style.setProperty('--skc-c', opt(el, 'color'));
      var layer = document.createElement('div');
      layer.className = 'skc-layer';
      layer.setAttribute('aria-hidden', 'true');
      el.appendChild(layer);
      var s = el._skc = { layer: layer, items: items(el), groups: [], loaded: true, timer: null, visible: false, waiting: false };
      loading(el, true);
      // Keep the bars fitted while loading if the box changes size.
      var w = el.clientWidth;
      new ResizeObserver(function () {
        if (el.clientWidth === w) return;
        w = el.clientWidth;
        if (!s.loaded) build(el);
      }).observe(el);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._skc;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.started) { s.started = true; cycle(e.target); }
          else if (s.waiting) cycle(e.target);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-skeleton-to-content]').forEach(function (el) {
        if (el._skc) return;
        if (reduced() && el.dataset.manual === undefined) { el._skc = { done: true }; return; }
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.skeletonToContent = function (el, loaded) {
      var s = el._skc;
      if (!s || s.done) return;
      clearTimeout(s.timer);
      if (loaded === false) { loading(el); return; }
      if (reduced()) { s.loaded = true; el.removeAttribute('aria-busy'); s.layer.style.display = 'none'; anime.utils.set(s.items, { opacity: 1 }); return; }
      arrive(el);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<article data-skeleton-to-content data-manual>
  <img src="avatar.jpg" alt="">
  <h3>Maya Okafor</h3>
  <p>Shipped the new sign-up flow today.</p>
</article>
<script>
  loadPost().then(fill).then(() => animacious.skeletonToContent(document.querySelector("article")));
</script>