← Page transitions

Paper Fold Transition

The view creases into strips that concertina up to the top edge like folded paper, then the next view unfolds downwards from there, crease by crease, with light and shade on every fold. As a page transition a sheet unfolds down over the page when a link is clicked and folds away on the next page. This page uses it, so try any link.

How to use it

  1. Add the snippet to every page of your site.
  2. Add data-paper-fold-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-paper-fold-transition="#hex"On a page, the colour of the sheet. Default #1e1b4b. In a slideshow, the colour behind the paper. Default #0f0b1a.
data-foldsHow many strips it folds into. Default 4.
data-durationTime to fold or unfold. Default 600 ms (650 ms in a slideshow).
data-intervalContained mode only: time each slide stays. Default 2200 ms.

Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Coming back with the Back button folds the sheet away again. Put data-paper-fold-transition on any other element with two or more children and the real views fold and unfold, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Paper Fold Transition: the view folds up like paper and unfolds as the
    // next one. It creases into strips that concertina up to the top edge, then
    // the next view unfolds downwards from there, crease by crease.
    // Usage: <body data-paper-fold-transition>   (add the snippet to every page)
    // Optional: data-paper-fold-transition="#hex" (on a page, the colour of the
    // sheet that unfolds over it, default #1e1b4b; in a slideshow, the colour
    // behind the paper, default #0f0b1a), data-folds (how many strips, default
    // 4), data-duration (ms for each half: default 600 on a page, 650 for a
    // slideshow).
    // Contained mode: put data-paper-fold-transition on any other element with
    // two or more children. Every data-interval ms (default 2200) the current
    // child folds away and the next one unfolds.
    var DEFAULTS = { paper: '#1e1b4b', backdrop: '#0f0b1a', folds: 4, duration: 600, slideDuration: 650, interval: 2200 };
    var KEY = 'animacious-paper-fold';

    var css =
      'html.pft-pre body{opacity:0}' +
      '.pft-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;perspective:1400px;perspective-origin:50% 40%}' +
      '.pft-contained{position:relative;overflow:hidden;display:grid}' +
      '.pft-contained>*{grid-area:1/1}' +
      '.pft-contained>.pft-fx{position:absolute;inset:0;z-index:2;pointer-events:none;perspective:1000px;perspective-origin:50% 40%}' +
      '.pft-strip{position:absolute;left:0;top:0;width:100%;overflow:hidden;backface-visibility:hidden}' +
      '.pft-strip>.pft-copy{position:absolute;left:0;margin:0;max-width:none}' +
      '.pft-strip>i{position:absolute;inset:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    var arriving = false;
    try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {}
    function unhide() { document.documentElement.classList.remove('pft-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('pft-pre');
      setTimeout(unhide, 3000);
    }

    function tween(duration, ease, paint, done) {
      var s = { t: 0 };
      paint(0);
      return anime.animate(s, {
        t: 1, duration: duration, ease: ease,
        onUpdate: function () { paint(s.t); },
        onComplete: function () { paint(1); if (done) done(); }
      });
    }

    // `count` strips of height h in `parent`; fill(strip, i) puts content in
    // each. Returns fold(a): 0 lies flat, 1 is folded right up to the top edge.
    function sheet(parent, count, h, fill) {
      var strips = [];
      for (var i = 0; i < count; i++) {
        var s = document.createElement('div');
        s.className = 'pft-strip';
        s.style.height = Math.ceil(h) + 1 + 'px';
        fill(s, i);
        var shade = document.createElement('i');
        s.appendChild(shade);
        parent.appendChild(s);
        strips.push({ el: s, shade: shade });
      }
      return function fold(a) {
        // Just short of 90 degrees, so the folded sheet is a thin sliver.
        var th = a * 89 * Math.PI / 180, c = Math.cos(th), sn = Math.sin(th);
        strips.forEach(function (s, i) {
          // Even strips hinge on their top edge and lean back; odd strips hinge
          // on their bottom edge and lean back to meet them at the crease.
          var even = i % 2 === 0;
          var y = even ? i * h * c : (i + 1) * h * c - h;
          s.el.style.transformOrigin = even ? '50% 0' : '50% ' + h + 'px';
          s.el.style.transform = 'translateY(' + y.toFixed(2) + 'px) rotateX(' + ((even ? -1 : 1) * a * 89).toFixed(2) + 'deg)';
          s.el.style.visibility = a > 0.995 ? 'hidden' : '';
          // Faces tilted away from the light darken towards the crease.
          s.shade.style.background = even
            ? 'linear-gradient(rgba(255,255,255,' + (0.08 * sn).toFixed(3) + '),rgba(0,0,0,' + (0.3 * sn).toFixed(3) + '))'
            : 'linear-gradient(rgba(0,0,0,' + (0.6 * sn).toFixed(3) + '),rgba(0,0,0,' + (0.25 * sn).toFixed(3) + '))';
        });
      };
    }

    function linkFor(e) {
      var a = e.target.closest && e.target.closest('a[href]');
      if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
      if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
      var url = new URL(a.href, location.href);
      if (url.origin !== location.origin) return null;
      if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
      return url;
    }

    function foldsOf(node) { return Math.max(2, Math.min(12, Math.round(+node.dataset.folds || DEFAULTS.folds))); }

    // ---------- Page mode: a sheet of paper unfolds over the page ----------

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-paper-fold-transition') || DEFAULTS.paper;
      var count = foldsOf(body);
      var layer = null, anim, trip = 0;

      // Leaving: the sheet unfolds down from the top until it covers the page.
      // Arriving: it folds back up and away.
      function run(out, done) {
        if (anim) anim.pause();
        if (layer) layer.remove();
        layer = document.createElement('div');
        layer.className = 'pft-layer';
        layer.setAttribute('aria-hidden', 'true');
        var fold = sheet(layer, count, innerHeight / count, function (s) {
          s.style.background = 'linear-gradient(rgba(255,255,255,.05),rgba(0,0,0,.08)),' + color;
        });
        body.appendChild(layer);
        anim = tween(duration, 'inOut(3)', function (t) { fold(out ? 1 - t : t); }, function () {
          if (!out) { layer.remove(); layer = null; }
          if (done) done();
        });
      }
      if (arriving) run(false);
      unhide();

      document.addEventListener('click', function (e) {
        var url = linkFor(e);
        if (!url) return;
        e.preventDefault();
        var t = ++trip, gone = false;
        var go = function () { if (gone || t !== trip) return; gone = true; location.href = url.href; };
        try { sessionStorage.setItem(KEY, '1'); } catch (err) {}
        setTimeout(go, duration + 400);
        try { run(true, go); } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        try { sessionStorage.removeItem(KEY); } catch (err) {}
        run(false);
      });
    }

    // ---------- Contained mode: the real views fold and unfold ----------

    function containedMode(el, still) {
      el.classList.add('pft-contained');
      el.style.background = el.getAttribute('data-paper-fold-transition') || DEFAULTS.backdrop;
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var count = foldsOf(el);
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (still) return;

      // Strips each carrying a copy of `node`, lined up with the original.
      function paper(node, w, h) {
        var fx = document.createElement('div');
        fx.className = 'pft-fx';
        fx.setAttribute('aria-hidden', 'true');
        var fold = sheet(fx, count, h / count, function (s, k) {
          var c = node.cloneNode(true);
          c.removeAttribute('id');
          c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
          c.classList.add('pft-copy');
          c.style.width = w + 'px';
          c.style.height = h + 'px';
          c.style.visibility = 'visible';
          c.style.top = (-k * h / count).toFixed(2) + 'px';
          s.appendChild(c);
        });
        el.appendChild(fx);
        return { fx: fx, fold: fold };
      }

      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          var w = el.clientWidth, h = el.clientHeight;
          if (!w || !h) return;
          var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
          var a = paper(from, w, h);
          a.fold(0);
          from.style.visibility = 'hidden';
          tween(duration, 'inOut(2)', a.fold, function () {
            a.fx.remove();
            var b = paper(to, w, h);
            tween(duration, 'out(3)', function (t) { b.fold(1 - t); }, function () {
              to.style.visibility = 'visible';
              b.fx.remove();
              i = n;
            });
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var still = reduced();
      document.querySelectorAll('[data-paper-fold-transition]').forEach(function (el) {
        if (el._pft) return;
        el._pft = true;
        if (el === document.body) { if (!still) pageMode(el); }
        else if (el.children.length > 1) containedMode(el, still);
      });
      unhide();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<body data-paper-fold-transition>
  …
</body>