← Page transitions

Page Peel Transition

The current view peels back from a corner like a sheet of paper: the crease sweeps across, the curled-over back of the sheet catches the light, and the next view is waiting underneath. As a page transition a sheet folds down over the page when a link is clicked, and on the next page it peels off again. 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-page-peel-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-page-peel-transition="#hex"On a page, the colour of the sheet that folds over it. Default #1e1b4b.
data-cornerThe corner it peels from: bottom-right, bottom-left, top-right or top-left. Default bottom-right.
data-durationTime for each half of a page change. Default 750 ms (1200 ms for a whole slideshow peel).
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 peels the sheet away again. Put data-page-peel-transition on any other element with two or more children and the real views peel away one after another, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Page Peel Transition: the current view peels back from a corner like a
    // sheet of paper, its curling back sweeping across, to reveal the next view
    // lying underneath.
    // Usage: <body data-page-peel-transition>   (add the snippet to every page)
    // Optional: data-page-peel-transition="#hex" (on a page, the colour of the
    // sheet that folds down over it and peels off the next page, default
    // #1e1b4b), data-corner ("bottom-right", the default, "bottom-left",
    // "top-right" or "top-left"), data-duration (ms: default 750 for each half
    // of a page change, 1200 for a slideshow change).
    // Contained mode: put data-page-peel-transition on any other element with
    // two or more children. Every data-interval ms (default 2200) the current
    // child peels away to show the next.
    var DEFAULTS = { color: '#1e1b4b', corner: 'bottom-right', duration: 750, slideDuration: 1200, interval: 2200 };
    var KEY = 'animacious-page-peel';

    var css =
      'html.ppt-pre body{opacity:0}' +
      '.ppt-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
      '.ppt-contained{position:relative;overflow:hidden;display:grid}' +
      '.ppt-contained>*{grid-area:1/1}' +
      '.ppt-contained>.ppt-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
      '.ppt-page,.ppt-under,.ppt-flap{position:absolute;inset:0}' +
      '.ppt-page>.ppt-copy{position:absolute;left:0;top:0;margin:0;max-width:none}' +
      '.ppt-lift{position:absolute;inset:0;filter:drop-shadow(0 4px 10px rgba(0,0,0,.35))}';
    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('ppt-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('ppt-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(); }
      });
    }

    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;
    }

    // Cut a polygon by a straight line, keeping the side where f(point) <= 0.
    function cut(poly, f) {
      var out = [];
      for (var i = 0; i < poly.length; i++) {
        var a = poly[i], b = poly[(i + 1) % poly.length], fa = f(a), fb = f(b);
        if (fa <= 0) out.push(a);
        if ((fa <= 0) !== (fb <= 0)) {
          var k = fa / (fa - fb);
          out.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]);
        }
      }
      return out;
    }

    function shape(poly) {
      if (poly.length < 3) return 'polygon(0 0,0 0,0 0)';
      return 'polygon(' + poly.map(function (p) { return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'; }).join(',') + ')';
    }

    // A CSS gradient running along (dx, dy) across a w x h box, with stops
    // given as distances from the line through point P that is square to it.
    function along(w, h, dx, dy, P, stops) {
      var deg = Math.atan2(dx, -dy) * 180 / Math.PI;
      var len = Math.abs(w * dx) + Math.abs(h * dy);
      var start = (P[0] - (w / 2 - dx * len / 2)) * dx + (P[1] - (h / 2 - dy * len / 2)) * dy;
      return 'linear-gradient(' + deg.toFixed(2) + 'deg,' + stops.map(function (s) { return s[1] + ' ' + (start + s[0]).toFixed(1) + 'px'; }).join(',') + ')';
    }

    // The peel itself, over `fx` (w x h). `page` is the sheet that peels: a
    // copy of the view, or a plain coloured sheet. Returns set(p): p = 0 lies
    // flat, p = 1 is peeled right off.
    function peeler(fx, page, w, h, corner, back) {
      var right = corner.indexOf('left') < 0, bottom = corner.indexOf('top') < 0;
      // Work as if peeling from the bottom-right, then mirror into place.
      function place(p) { return [right ? p[0] : w - p[0], bottom ? p[1] : h - p[1]]; }
      var ux = 1, uy = 0.62, ul = Math.hypot(ux, uy);
      ux /= ul; uy /= ul;
      var D = w * ux + h * uy;
      var rect = [[0, 0], [w, 0], [w, h], [0, h]];

      var under = document.createElement('div');
      under.className = 'ppt-under';
      var lift = document.createElement('div');
      lift.className = 'ppt-lift';
      var flap = document.createElement('div');
      flap.className = 'ppt-flap';
      lift.appendChild(flap);
      page.classList.add('ppt-page');
      fx.appendChild(under);
      fx.appendChild(page);
      fx.appendChild(lift);

      var gx = right ? ux : -ux, gy = bottom ? uy : -uy;     // the peel's direction on screen
      return function set(p) {
        var s = D * 1.02 * p;
        var f = function (X) { return (X[0] - w) * ux + (X[1] - h) * uy + s; };
        var kept = cut(rect, f);
        var gone = cut(rect, function (X) { return -f(X); });
        var flapPoly = gone.map(function (X) {
          var k = 2 * f(X);
          return [X[0] - k * ux, X[1] - k * uy];
        });
        var F = place([w - s * ux, h - s * uy]);
        page.style.clipPath = shape(kept.map(place));
        page.style.visibility = kept.length < 3 ? 'hidden' : '';
        flap.style.clipPath = shape(flapPoly.map(place));
        lift.style.visibility = flapPoly.length < 3 || s < 1 ? 'hidden' : '';
        // The back of the sheet: shaded in the crease, catching the light
        // where it curls over, a little darker towards its far edge.
        var depth = Math.max(1, s);
        flap.style.background = along(w, h, -gx, -gy, F, [
          [0, 'color-mix(in srgb,' + back + ' 62%,#000)'],
          [Math.min(depth * 0.12, 18), 'color-mix(in srgb,' + back + ' 86%,#000)'],
          [depth * 0.45, back],
          [depth, 'color-mix(in srgb,' + back + ' 88%,#000)']
        ]);
        // A soft shadow on the view underneath, along the crease.
        under.style.clipPath = shape(gone.map(place));
        under.style.background = along(w, h, gx, gy, F, [
          [0, 'rgba(0,0,0,.4)'],
          [Math.min(40, depth * 0.35 + 6), 'rgba(0,0,0,0)']
        ]);
      };
    }

    function cornerOf(node) {
      var c = (node.dataset.corner || DEFAULTS.corner).toLowerCase();
      return /^(top|bottom)-(left|right)$/.test(c) ? c : DEFAULTS.corner;
    }

    // ---------- Page mode: a sheet folds down over the page, and peels off the next ----------

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-page-peel-transition') || DEFAULTS.color;
      var corner = cornerOf(body);
      var fx = null, anim, trip = 0;

      function run(out, done) {
        if (anim) anim.pause();
        if (fx) fx.remove();
        fx = document.createElement('div');
        fx.className = 'ppt-layer';
        fx.setAttribute('aria-hidden', 'true');
        body.appendChild(fx);
        var sheet = document.createElement('div');
        sheet.style.background = color;
        var set = peeler(fx, sheet, innerWidth, innerHeight, corner, 'color-mix(in srgb,' + color + ' 55%,#fff)');
        var mine = fx;
        anim = tween(duration, out ? 'inOut(2)' : 'inOut(2)', function (t) { set(out ? 1 - t : t); }, function () {
          if (!out) { mine.remove(); if (fx === mine) fx = 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 view peels away ----------

    function containedMode(el, still) {
      el.classList.add('ppt-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var corner = cornerOf(el);
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (still) return;

      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + duration,
        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 fx = document.createElement('div');
          fx.className = 'ppt-fx';
          fx.setAttribute('aria-hidden', 'true');
          el.appendChild(fx);
          var page = document.createElement('div');
          var copy = from.cloneNode(true);
          copy.removeAttribute('id');
          copy.querySelectorAll('[id]').forEach(function (x) { x.removeAttribute('id'); });
          copy.classList.add('ppt-copy');
          copy.style.width = w + 'px';
          copy.style.height = h + 'px';
          page.appendChild(copy);
          var set = peeler(fx, page, w, h, corner, '#f4f1ea');
          set(0);
          to.style.visibility = 'visible';
          from.style.visibility = 'hidden';
          tween(duration, 'inOut(2)', set, function () {
            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-page-peel-transition]').forEach(function (el) {
        if (el._ppt) return;
        el._ppt = 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-page-peel-transition>
  …
</body>