← Loaders

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

  1. Paste the snippet just before </head>.
  2. Add <div data-percentage-preloader></div> as the first thing in <body>. Anything inside it, such as your logo, shows above the number.
  3. 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-durationTime for the count. Default 2200 ms.
data-bgCover colour. Default #12091f.
data-colorText colour. Default #fff.
data-accentBar 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-containedCover its parent instead of the whole window.
data-loopCome 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.

1 · Snippet
<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>
2 · Your HTML
<body>
  <div data-percentage-preloader>Studio North</div>
  …
</body>

<script>
  document.addEventListener("preloader:done", () => {
    // start your page's entrance animations here
  });
</script>