← Page transitions

Whip Pan

A fast, motion-blurred slide from one view to the next, like a camera swung hard to one side: the views smear sideways as they race past, then snap sharp as the new one lands. As a page transition the page whips away when a link is clicked and the next page whips in from the other side. 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-whip-pan to each page’s <body>.

Options

Add any of these attributes to the same element.

data-whip-pan="#hex"The colour behind the page while it whips. Default #0f0b1a.
data-directionWhich way the view travels: left, right, up or down. Default left.
data-blurStrength of the motion blur; 0 turns it off. Default 1.
data-durationTime for each half of a page change. Default 380 ms (650 ms for a whole slideshow change).
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 whips the page in again. Put data-whip-pan on any other element with two or more children and it whips from one child to the next, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Whip Pan: a fast, motion-blurred slide from one view to the next, like a
    // camera swung hard to one side. The views smear sideways as they race past
    // and snap sharp again as the new one lands.
    // Usage: <body data-whip-pan>   (add the snippet to every page)
    // Optional: data-whip-pan="#hex" (colour behind the page while it whips,
    // default #0f0b1a), data-direction (left, right, up or down: which way the
    // view travels, default left), data-blur (strength of the smear, default 1),
    // data-duration (ms; default 380 for each half on a page, 650 for a
    // slideshow change).
    // Contained mode: put data-whip-pan on any other element with two or more
    // children. Every data-interval ms (default 2200) it whips to the next child.
    var DEFAULTS = { color: '#0f0b1a', direction: 'left', blur: 1, duration: 380, slideDuration: 650, interval: 2200 };
    var KEY = 'animacious-whip-pan';
    var DIRS = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };

    var css =
      'html.whp-pre body{opacity:0}' +
      '.whp-contained{position:relative;overflow:hidden;display:grid}' +
      '.whp-contained>*{grid-area:1/1}';
    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('whp-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('whp-pre');
      setTimeout(unhide, 3000);
    }

    // A blur in one direction only, as an SVG filter: its amount is changed
    // every frame to follow the speed.
    var NS = 'http://www.w3.org/2000/svg';
    var count = 0;
    function streak() {
      var id = 'whp-blur-' + (++count);
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.setAttribute('width', '0');
      svg.setAttribute('height', '0');
      svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
      svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="0 0" edgeMode="duplicate"/></filter>';
      document.body.appendChild(svg);
      var blur = svg.querySelector('feGaussianBlur');
      return {
        url: 'url(#' + id + ')',
        set: function (x, y) { blur.setAttribute('stdDeviation', x.toFixed(1) + ' ' + y.toFixed(1)); }
      };
    }

    // Slow away, a violent middle, a hard landing: an in-out curve of power 4,
    // and its slope, which sets the blur.
    function ease(t) { return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2; }
    function speed(t) { return t < 0.5 ? 32 * t * t * t : 4 * Math.pow(-2 * t + 2, 3); }

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

    function dirOf(node) { return DIRS[node.dataset.direction] || DIRS[DEFAULTS.direction]; }
    function blurOf(node) { var b = parseFloat(node.dataset.blur); return isNaN(b) ? DEFAULTS.blur : Math.max(0, b); }

    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 pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-whip-pan') || DEFAULTS.color;
      var d = dirOf(body), power = blurOf(body), html = document.documentElement;
      var fx = streak(), anim, trip = 0, saved = null;

      // Leaving: the page accelerates away. Arriving: it races in from the
      // other side and brakes hard. Each is one half of the whole curve.
      function run(out, done) {
        if (anim) anim.pause();
        if (saved === null) saved = { bg: html.style.background, ov: html.style.overflow };
        html.style.background = color;
        html.style.overflow = 'hidden';
        var W = innerWidth, H = innerHeight;
        anim = tween(duration, function (t) {
          var u = out ? t / 2 : 0.5 + t / 2;
          var p = ease(u), v = speed(u);
          var off = out ? -p : 1 - p;
          body.style.transform = 'translate(' + (-d[0] * off * W).toFixed(1) + 'px,' + (-d[1] * off * H).toFixed(1) + 'px)';
          fx.set(Math.abs(d[0]) * v * W * 0.01 * power, Math.abs(d[1]) * v * H * 0.01 * power);
          body.style.filter = v > 0.05 ? fx.url : '';
        }, function () {
          if (!out) {
            body.style.transform = body.style.filter = '';
            html.style.background = saved.bg;
            html.style.overflow = saved.ov;
            saved = 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);
      });
    }

    function containedMode(el, still) {
      el.classList.add('whp-contained');
      el.style.background = el.getAttribute('data-whip-pan') || DEFAULTS.color;
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var d = dirOf(el), power = blurOf(el);
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (still) return;
      var fx = streak();

      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;
          var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
          to.style.visibility = 'visible';
          // The two views travel as one strip, the new one right behind the old.
          tween(duration, function (t) {
            var p = ease(t), v = speed(t);
            var bx = Math.abs(d[0]) * v * W * 0.01 * power, by = Math.abs(d[1]) * v * H * 0.01 * power;
            fx.set(bx, by);
            var f = v > 0.05 ? fx.url : '';
            from.style.transform = 'translate(' + (d[0] * p * W).toFixed(1) + 'px,' + (d[1] * p * H).toFixed(1) + 'px)';
            to.style.transform = 'translate(' + (d[0] * (p - 1) * W).toFixed(1) + 'px,' + (d[1] * (p - 1) * H).toFixed(1) + 'px)';
            from.style.filter = to.style.filter = f;
          }, function () {
            from.style.visibility = 'hidden';
            from.style.transform = from.style.filter = '';
            to.style.transform = to.style.filter = '';
            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-whip-pan]').forEach(function (el) {
        if (el._whp) return;
        el._whp = 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-whip-pan>
  …
</body>