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
- Paste the snippet just before
</head>. - Put your real content in a box and add
data-skeleton-to-contentto the box. Headings, paragraphs, list items, images and buttons each get skeleton bars, one per line of text. Adddata-skeletonto anything else that should get a block, such as an avatar. - Add
data-manualand callanimacious.skeletonToContent(el)once your data is in. Without it the content arrives afterdata-delay.
Options
Add any of these attributes to the same element.
data-delay | How long it shows the skeleton before the content arrives. Default 1600 ms. |
data-manual | Stay as a skeleton until animacious.skeletonToContent(el). |
data-items | Your own CSS selector for what gets skeleton bars. |
data-color | Colour of the bars. Default #e4e1ec. |
data-loop | Go back to the skeleton after a pause and load again. |
data-hold | How 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.
<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><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>