Percentage Preloader
A full-page cover counts from 0 to 100% while a thin bar fills beneath the number. It waits for the page to finish loading, then the number slides away and the cover lifts off like a blind, its bottom edge rounding as it rises. Put your logo or name inside it and it shows above the number.
Hello there.
Studio North
How to use it
- Paste the snippet just before
</head>. - Add
<div data-percentage-preloader></div>as the first thing in<body>. Anything inside it, such as your logo, shows above the number. - That’s it: it covers the page straight away and lifts once the count reaches 100% and the page has loaded.
Options
Add any of these attributes to the same element.
data-duration | Time for the count. Default 2200 ms. |
data-bg | Cover colour. Default #12091f. |
data-color | Text colour. Default #fff. |
data-accent | Bar colour. Default #a855f7. |
data-wait="false" | Don’t wait for the page’s load event: lift as soon as the count ends. Otherwise the count holds at 90% until the page has loaded. |
data-contained | Cover its parent instead of the whole window. |
data-loop | Come back and count again after lifting. Handy for demos. |
While it covers the page it stops the page scrolling. When it has gone it hides itself and fires a preloader:done event, so you can start your own entrance animations then. With reduced motion there’s no count: it simply disappears once the page has loaded.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Percentage Preloader: a full-page cover counts from 0 to 100% with a thin
// bar filling beneath the number, waits for the page to finish loading,
// then the number slides away and the cover lifts off the page like a blind.
// Anything you put inside it (a logo, a name) shows above the number.
// Usage: <div data-percentage-preloader>Studio Name</div> (first thing in <body>)
// Optional: data-duration (ms for the count, default 2200), data-bg (cover
// colour, default #12091f), data-color (text colour, default #fff),
// data-accent (bar colour, default #a855f7), data-wait="false" (don't wait
// for the page's load event), data-contained (cover its parent instead of
// the whole window), data-loop (come back and count again: handy for demos).
// When it has gone it fires a "preloader:done" event on itself.
var DEFAULTS = { duration: 2200, bg: '#12091f', color: '#fff', accent: '#a855f7' };
var css =
'.ppl{position:fixed;inset:0;z-index:9999;display:grid;place-content:center;justify-items:center;gap:0.6em;padding:1.5em;box-sizing:border-box;container-type:size;font-variant-numeric:tabular-nums;text-align:center}' +
'.ppl.ppl-in{position:absolute;z-index:5}' +
'.ppl-inner{display:grid;justify-items:center;gap:0.5em;will-change:transform,opacity}' +
'.ppl-label{font-size:clamp(11px,5cqmin,20px);font-weight:600;letter-spacing:.2em;text-transform:uppercase;opacity:.7}' +
'.ppl-num{font-size:clamp(34px,30cqmin,180px);font-weight:800;line-height:1;letter-spacing:-.04em}' +
'.ppl-num small{font-size:.38em;font-weight:700;margin-left:.06em;vertical-align:.95em;letter-spacing:0;opacity:.7}' +
'.ppl-bar{width:min(18em,60cqw);height:3px;border-radius:3px;overflow:hidden;background:rgba(255,255,255,.15)}' +
'.ppl-fill{height:100%;transform:scaleX(0);transform-origin:0 50%}' +
'.ppl-lock{overflow:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function setup(el) {
var d = el.dataset;
var contained = on(d.contained);
var loop = on(d.loop);
var wait = d.wait !== 'false' && !loop;
var duration = Math.max(300, +d.duration || DEFAULTS.duration);
var label = el.innerHTML.trim();
el.classList.add('ppl');
if (contained) {
el.classList.add('ppl-in');
if (el.parentNode && getComputedStyle(el.parentNode).position === 'static') el.parentNode.style.position = 'relative';
}
el.style.background = d.bg || DEFAULTS.bg;
el.style.color = d.color || DEFAULTS.color;
el.setAttribute('role', 'progressbar');
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
el.innerHTML =
'<div class="ppl-inner" aria-hidden="true">' +
(label ? '<div class="ppl-label">' + label + '</div>' : '') +
'<div class="ppl-num"><span>0</span><small>%</small></div>' +
'<div class="ppl-bar"><div class="ppl-fill"></div></div></div>';
var inner = el.querySelector('.ppl-inner');
var num = el.querySelector('.ppl-num span');
var fill = el.querySelector('.ppl-fill');
fill.style.background = d.accent || DEFAULTS.accent;
if (!contained) document.documentElement.classList.add('ppl-lock');
function loaded() { return document.readyState === 'complete'; }
function render(v) {
var n = Math.round(v);
num.textContent = n;
fill.style.transform = 'scaleX(' + (v / 100).toFixed(4) + ')';
el.setAttribute('aria-valuenow', n);
}
function clip(p) {
// p: 0 = covering, 1 = fully lifted. The bottom edge rounds as it rises.
el.style.clipPath = p ? 'inset(0 0 ' + (p * 100).toFixed(2) + '% 0 round 0 0 ' + Math.min(2.5, p * 12).toFixed(2) + 'em ' + Math.min(2.5, p * 12).toFixed(2) + 'em)' : '';
}
function gone() {
el.style.display = 'none';
el.setAttribute('aria-hidden', 'true');
if (!contained) document.documentElement.classList.remove('ppl-lock');
el.dispatchEvent(new CustomEvent('preloader:done', { bubbles: true }));
}
if (reduced()) {
render(100);
if (loop || !wait || loaded()) gone();
else window.addEventListener('load', gone);
return null;
}
var state = { cur: null, visible: true, pending: false, first: true };
var count = { v: 0 };
function run(anim) { state.cur = anim; if (!state.visible) anim.pause(); return anim; }
function cycle() {
state.pending = false;
count.v = 0;
render(0);
clip(0);
anime.utils.set(inner, { y: 0, opacity: 1 });
if (!state.first) anime.animate(el, { opacity: { from: 0, to: 1 }, duration: 350, ease: 'out(2)' });
state.first = false;
run(anime.animate(count, {
v: wait && !loaded() ? 90 : 100,
duration: duration,
ease: 'inOut(2.4)',
onUpdate: function () { render(count.v); },
onComplete: function () {
state.cur = null;
if (count.v >= 100) return lift();
// Hold at 90% until the page has loaded, then finish the count.
function finish() {
run(anime.animate(count, { v: 100, duration: 450, ease: 'out(2)', onUpdate: function () { render(count.v); }, onComplete: function () { state.cur = null; lift(); } }));
}
if (loaded()) finish(); else window.addEventListener('load', finish);
},
}));
}
function lift() {
var lid = { p: 0 };
var tl = anime.createTimeline({
onComplete: function () {
state.cur = null;
if (!loop) return gone();
setTimeout(function () {
if (state.visible) cycle(); else state.pending = true;
}, 1600);
},
});
tl.add(inner, { y: '-1.2em', opacity: 0, duration: 450, ease: 'in(2)' }, 250)
.add(lid, { p: 1, duration: 900, ease: 'inOut(4)', onUpdate: function () { clip(lid.p); } }, 450);
run(tl);
}
function start() { cycle(); }
if (!contained) { start(); return; }
var started = false;
new IntersectionObserver(function (entries) {
state.visible = entries[0].isIntersecting;
if (!state.visible) { if (state.cur) state.cur.pause(); return; }
if (!started) { started = true; return start(); }
if (state.pending) cycle();
else if (state.cur) state.cur.play();
}).observe(el.parentNode || el);
}
function init() {
document.querySelectorAll('[data-percentage-preloader]').forEach(function (el) {
if (el._ppl) return;
el._ppl = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body>
<div data-percentage-preloader>Studio North</div>
…
</body>
<script>
document.addEventListener("preloader:done", () => {
// start your page's entrance animations here
});
</script>