Top Loading Bar
A thin bar along the very top of the page creeps across while it loads, quickly at first and then slower and slower, so it never quite arrives. When loading is done it shoots to the end and fades away. A soft glow at its tip shows it’s still alive. Drop it in and it follows the page loading, or start and finish it from your own code when you fetch data or change route.
example.com/blog
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-top-loading-bar>anywhere in the<body>. If the page is still loading, it starts straight away and finishes when the page has loaded. - For your own loading, call
animacious.topLoadingBar.start()before andanimacious.topLoadingBar.done()after.
Options
Add any of these attributes to the same element.
data-color | Bar colour. Default #6a0dad. |
data-height | Bar thickness in pixels. Default 3. |
data-contained | Sit along the top of the parent element instead of the window. Give the parent position: relative. |
data-loop | Start, finish and start again while it’s on screen, like the demo. |
animacious.topLoadingBar.set(0.5) jumps to a value between 0 and 1. Each call takes an optional element as its last argument; without one it acts on every bar. The bar is a progressbar for screen readers while it’s showing. With reduced motion it still shows progress, but jumps instead of gliding.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Top Loading Bar: a thin bar along the very top of the page that creeps
// across while something loads, slowing as it goes so it never quite
// arrives, then shoots to the end and fades away when loading is done.
// A glow at its tip shows it's alive.
// Usage: <div data-top-loading-bar></div> (anywhere in the body)
// If the page is still loading when the script runs, it starts straight
// away and finishes when the page has loaded. For your own loading
// (fetching data, changing route in an app) call
// animacious.topLoadingBar.start() and animacious.topLoadingBar.done().
// Optional: data-color (default #6a0dad), data-height (px, default 3),
// data-contained (sit along the top of the parent element instead of the
// window; give the parent position: relative), data-loop (start, finish
// and start again while on screen, for demos).
// Script use: animacious.topLoadingBar.start(el?), .set(0…1, el?),
// .done(el?): without el they act on every bar.
var DEFAULTS = { color: '#6a0dad', height: 3 };
var css =
'.tlb{position:fixed;left:0;right:0;top:0;z-index:2147483000;pointer-events:none;clip-path:inset(0 0 -12px 0);opacity:0;transition:opacity .35s ease}' +
'.tlb.tlb-contained{position:absolute;z-index:3}' +
'.tlb.tlb-on{opacity:1;transition-duration:.1s}' +
'.tlb-fill{position:absolute;inset:0;background:currentColor;transform:translateX(-100%);transition:transform .45s cubic-bezier(.2,.7,.3,1)}' +
'.tlb-fill::after{content:"";position:absolute;right:0;top:0;bottom:0;width:90px;' +
'box-shadow:0 0 10px currentColor,0 0 5px currentColor;opacity:.9;transform:rotate(2deg) translateY(-3px)}' +
'.tlb-fill::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent 70%,rgba(255,255,255,.55));animation:tlb-shine 1.1s linear infinite;background-size:200px 100%;background-repeat:repeat-x}' +
'@keyframes tlb-shine{to{background-position:200px 0}}' +
'@media (prefers-reduced-motion:reduce){.tlb-fill{transition:none}.tlb-fill::before{animation:none}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var bars = [];
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function show(b, p) {
b.p = p;
b.fill.style.transform = 'translateX(' + ((p - 1) * 100).toFixed(2) + '%)';
b.el.setAttribute('aria-valuenow', String(Math.round(p * 100)));
}
// Creep on: big steps early, smaller and smaller ones as it nears 92%.
function trickle(b) {
clearTimeout(b.tick);
if (!b.running) return;
var room = 0.92 - b.p;
if (room > 0.002) show(b, b.p + room * (0.08 + Math.random() * 0.22));
b.tick = setTimeout(function () { trickle(b); }, 260 + Math.random() * 520);
}
function start(b) {
clearTimeout(b.tick); clearTimeout(b.hide);
b.running = true;
b.fill.style.transition = 'none';
show(b, 0);
void b.fill.offsetWidth; // apply the reset before animating again
b.fill.style.transition = '';
b.el.classList.add('tlb-on');
b.el.removeAttribute('aria-hidden');
show(b, 0.06 + Math.random() * 0.08);
b.tick = setTimeout(function () { trickle(b); }, 220);
}
function done(b) {
if (!b.running) return;
b.running = false;
clearTimeout(b.tick);
show(b, 1);
b.hide = setTimeout(function () {
b.el.classList.remove('tlb-on');
b.el.setAttribute('aria-hidden', 'true');
if (b.loop && b.visible) b.tick = setTimeout(function () { cycle(b); }, 900);
}, 450);
}
// Demo mode: load for a random while, finish, pause, go again.
function cycle(b) {
if (!b.visible) return;
start(b);
b.tick2 = setTimeout(function () { done(b); }, 1800 + Math.random() * 1600);
}
function setup(el) {
var d = el.dataset;
el.classList.add('tlb');
if (d.contained !== undefined && d.contained !== 'false') el.classList.add('tlb-contained');
el.style.height = (+d.height || DEFAULTS.height) + 'px';
el.style.color = d.color || DEFAULTS.color;
el.setAttribute('role', 'progressbar');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
el.setAttribute('aria-hidden', 'true');
el.textContent = '';
var fill = document.createElement('div');
fill.className = 'tlb-fill';
el.appendChild(fill);
var b = { el: el, fill: fill, p: 0, running: false, loop: d.loop !== undefined && d.loop !== 'false', visible: false };
el._tlbBar = b;
bars.push(b);
if (b.loop) {
if (reduced()) {
el.classList.add('tlb-on');
show(b, 0.68);
return;
}
new IntersectionObserver(function (entries) {
var was = b.visible;
b.visible = entries[0].isIntersecting;
if (b.visible && !was) cycle(b);
if (!b.visible) { clearTimeout(b.tick); clearTimeout(b.tick2); b.running = false; el.classList.remove('tlb-on'); }
}).observe(el.parentNode || el);
} else if (document.readyState !== 'complete') {
start(b);
window.addEventListener('load', function () { done(b); });
}
}
function pick(el) {
if (el) return el._tlbBar ? [el._tlbBar] : [];
return bars;
}
window.animacious = window.animacious || {};
window.animacious.topLoadingBar = {
start: function (el) { pick(el).forEach(start); },
set: function (v, el) {
pick(el).forEach(function (b) {
if (!b.running) start(b);
show(b, Math.max(0, Math.min(1, +v || 0)));
});
},
done: function (el) { pick(el).forEach(done); },
};
function init() {
document.querySelectorAll('[data-top-loading-bar]').forEach(function (el) {
if (el._tlb) return;
el._tlb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-top-loading-bar data-color="#6a0dad"></div>
<!-- around your own loading: -->
<script>
animacious.topLoadingBar.start();
fetch("/api/posts")
.then((r) => r.json())
.then(render)
.finally(() => animacious.topLoadingBar.done());
</script>