← Loaders

Page Flip Loader

An open book whose pages flip over from right to left, one after another, over and over. Each page lifts and turns in 3D, darkening as it tips away from the light, and lands flat on the left as the next one goes. Size it with font-size and colour the cover with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-page-flip-loader> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime between pages. Default 700 ms.
data-pagesPages turning at once, 1 to 4. Default 3.
data-paperPage colour. Default #fff.
data-lines="false"Blank pages, without the lines of text.

It’s 3.2em wide and 2.1em tall. It only moves while it’s on screen. With reduced motion a page rests part way through its turn.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Page Flip Loader: an open book whose pages flip over from right to left,
    // one after another, over and over. Each page lifts, curls over in 3D,
    // darkening as it turns away from the light, and lands on the left.
    // Sized with font-size (3.2em wide), coloured with color (the cover).
    // It only moves while it's on screen.
    // Usage: <div data-page-flip-loader aria-label="Loading"></div>
    // Optional: data-speed (ms between pages, default 700), data-pages (pages
    // in the air at once, 1 to 4, default 3), data-paper (page colour,
    // default #fff), data-lines="false" (blank pages).
    var DEFAULTS = { speed: 700, pages: 3, paper: '#fff' };
    var W = 3.2, H = 2.1; // em, open book

    var css =
      '.pfl{position:relative;display:inline-block;width:' + W + 'em;height:' + H + 'em;vertical-align:middle;perspective:' + W * 3 + 'em}' +
      '.pfl-cover{position:absolute;inset:-.1em -.12em -.16em;border-radius:.12em;background:currentColor}' +
      '.pfl-book{position:absolute;inset:0;transform-style:preserve-3d}' +
      '.pfl-page,.pfl-leaf{position:absolute;top:0;width:50%;height:100%}' +
      '.pfl-l{left:0;border-radius:.06em 0 0 .06em}' +
      '.pfl-r{left:50%;border-radius:0 .06em .06em 0}' +
      '.pfl-leaf{left:50%;transform-origin:0 50%;transform-style:preserve-3d;will-change:transform}' +
      '.pfl-face{position:absolute;inset:0;overflow:hidden;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.pfl-front{border-radius:0 .06em .06em 0}' +
      '.pfl-back{transform:rotateY(180deg);border-radius:.06em 0 0 .06em}' +
      '.pfl-shade{position:absolute;inset:0;background:#000;opacity:0}' +
      '.pfl-lines{position:absolute;left:18%;right:18%;top:20%;bottom:20%;opacity:.28;background:repeating-linear-gradient(currentColor 0 .07em,transparent .07em .24em)}' +
      '.pfl-r .pfl-lines,.pfl-front .pfl-lines{left:14%;right:22%}' +
      '.pfl-l .pfl-lines,.pfl-back .pfl-lines{left:22%;right:14%}' +
      '.pfl-spine{position:absolute;left:50%;top:0;bottom:0;width:.18em;margin-left:-.09em;background:linear-gradient(90deg,transparent,rgba(0,0,0,.14),transparent)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function span(parent, cls) {
      var s = document.createElement('span');
      s.className = cls;
      parent.appendChild(s);
      return s;
    }

    function inOut(v) { return v < 0.5 ? 4 * v * v * v : 1 - Math.pow(-2 * v + 2, 3) / 2; }

    function build(el) {
      var d = el.dataset;
      var n = Math.max(1, Math.min(4, Math.round(+d.pages) || DEFAULTS.pages));
      var paper = d.paper || DEFAULTS.paper;
      var lines = d.lines !== 'false';
      el.classList.add('pfl');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';

      var cover = span(el, 'pfl-cover');
      cover.setAttribute('aria-hidden', 'true');
      var book = span(el, 'pfl-book');
      book.setAttribute('aria-hidden', 'true');
      function paperise(p) {
        p.style.background = paper;
        if (lines) span(p, 'pfl-lines');
      }
      paperise(span(book, 'pfl-page pfl-l'));
      paperise(span(book, 'pfl-page pfl-r'));
      var leaves = [];
      for (var i = 0; i < n; i++) {
        var leaf = span(book, 'pfl-leaf');
        var front = span(leaf, 'pfl-face pfl-front');
        var back = span(leaf, 'pfl-face pfl-back');
        paperise(front);
        paperise(back);
        leaves.push({ el: leaf, fs: span(front, 'pfl-shade'), bs: span(back, 'pfl-shade') });
      }
      span(book, 'pfl-spine');

      var speed = Math.max(200, +d.speed || DEFAULTS.speed);
      var flip = speed * 1.6;     // each page takes a little longer than the gap
      var cycle = speed * n;      // after this every leaf is back where it began

      function draw(t) {
        leaves.forEach(function (l, i) {
          var u = ((t - i * speed) % cycle + cycle) % cycle;
          var v = inOut(Math.min(1, u / flip));
          var a = -180 * v;
          // The turning face darkens as it tips away from the light. Each leaf
          // sits a hair above the last so the flat ones don't flicker.
          var lift = Math.sin(v * Math.PI);
          l.el.style.transform = 'rotateY(' + a.toFixed(2) + 'deg) translateZ(' + (0.002 + 0.01 * i).toFixed(3) + 'em)';
          l.fs.style.opacity = (0.32 * Math.min(1, v * 2) * lift).toFixed(3);
          l.bs.style.opacity = (0.32 * Math.min(1, (1 - v) * 2) * lift).toFixed(3);
        });
      }

      if (reduced()) { draw(flip * 0.4); return null; }
      draw(0);
      var t = 0, last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          draw(t);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-page-flip-loader]').forEach(function (el) {
        if (el._pfl) return;
        el._pfl = true;
        var timer = build(el);
        if (!timer) return;
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? timer.play() : timer.pause();
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-page-flip-loader style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>