← Page transitions

Ripple Transition

The view ripples like water into the next: a ring of ripples spreads out from the middle, bending the picture as it passes, and the next view wells up inside the ring as it travels outwards. Tap the demo to drop a ripple where you touch. As a page transition, water spreads out in rings from the link you click and drains away the same way 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-ripple-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-ripple-transition="#hex"On a page, the colour of the water that ripples over it. Default #1e1b4b.
data-strengthHow far the ripples bend the picture in a slideshow; 0 turns the bending off. Default 1.
data-durationTime for each half of a page change. Default 700 ms (1400 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. Put data-ripple-transition on any other element with two or more children and the real views ripple into each other, like the demo above; tap it to send a ripple from that spot. The bending uses an SVG displacement filter; Safari can’t apply that to HTML, so there the next view spreads in as a circle with the ripples’ sheen.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ripple Transition: the view ripples like water into the next. A ring of
    // ripples spreads out from the middle, bending the picture as it passes,
    // and the next view rises up inside the ring as it travels outwards.
    // Usage: <body data-ripple-transition>   (add the snippet to every page)
    // Optional: data-ripple-transition="#hex" (on a page, the colour of the
    // water that ripples over it, default #1e1b4b), data-strength (how far the
    // ripples bend the picture, default 1), data-duration (ms: default 700 for
    // each half of a page change, 1400 for a slideshow change).
    // Contained mode: put data-ripple-transition on any other element with two
    // or more children. Every data-interval ms (default 2200) the next child
    // ripples in from the middle; tap it to send the ripple from that spot.
    var DEFAULTS = { color: '#1e1b4b', strength: 1, duration: 700, slideDuration: 1400, interval: 2200 };
    var KEY = 'animacious-ripple';
    var FRAMES = 28;
    var uid = 0;

    var css =
      'html.rpl-pre body{opacity:0}' +
      '.rpl-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
      '.rpl-contained{position:relative;overflow:hidden;display:grid}' +
      '.rpl-contained>*:not(.rpl-defs):not(.rpl-sheen){grid-area:1/1}' +
      '.rpl-defs{position:absolute;width:0;height:0;overflow:hidden}' +
      '.rpl-sheen{position:absolute;inset:0;z-index:2;pointer-events:none;mix-blend-mode:soft-light}';
    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('rpl-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('rpl-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;
    }

    // The furthest corner from (x, y).
    function reach(w, h, x, y) {
      return Math.max(Math.hypot(x, y), Math.hypot(w - x, y), Math.hypot(x, h - y), Math.hypot(w - x, h - y));
    }

    // ---------- Page mode: a ring of water spreads over the page ----------

    // Water spreading from (x, y): a filled disc with ripples running ahead of
    // it (covering), or a hole opening up with ripples round its rim (clearing).
    function paintWater(ctx, w, h, color, t, x, y, covering) {
      var far = reach(w, h, x, y), lam = Math.max(18, Math.min(w, h) * 0.06);
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = color;
      ctx.strokeStyle = color;
      var R = (far + lam * 4) * t;
      if (covering) {
        ctx.beginPath();
        ctx.arc(x, y, Math.max(0, R - lam * 3), 0, Math.PI * 2);
        ctx.fill();
        for (var k = 0; k < 4; k++) {
          var r = R - lam * 3 + lam * (k + 0.6);
          if (r <= 0) continue;
          ctx.globalAlpha = 0.75 - k * 0.17;
          ctx.lineWidth = lam * (0.55 - k * 0.1);
          ctx.beginPath();
          ctx.arc(x, y, r, 0, Math.PI * 2);
          ctx.stroke();
        }
      } else {
        ctx.beginPath();
        ctx.rect(0, 0, w, h);
        ctx.arc(x, y, R, 0, Math.PI * 2, true);
        ctx.fill();
        for (var j = 0; j < 4; j++) {
          var q = R - lam * (j + 0.6);
          if (q <= 0) continue;
          ctx.globalAlpha = (0.7 - j * 0.16) * (1 - t * 0.5);
          ctx.lineWidth = lam * (0.5 - j * 0.09);
          ctx.beginPath();
          ctx.arc(x, y, q, 0, Math.PI * 2);
          ctx.stroke();
        }
      }
      ctx.globalAlpha = 1;
    }

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-ripple-transition') || DEFAULTS.color;
      var c = null, anim, trip = 0;

      function run(out, x, y, done) {
        if (anim) anim.pause();
        if (c) c.remove();
        c = document.createElement('canvas');
        c.className = 'rpl-layer';
        c.setAttribute('aria-hidden', 'true');
        body.appendChild(c);
        var w = c.clientWidth, h = c.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
        c.width = Math.round(w * dpr);
        c.height = Math.round(h * dpr);
        var ctx = c.getContext('2d');
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        if (x == null) { x = w / 2; y = h / 2; }
        var mine = c;
        anim = tween(duration, out ? 'in(1.6)' : 'out(1.8)', function (t) { paintWater(ctx, w, h, color, t, x, y, out); }, function () {
          if (!out) { mine.remove(); if (c === mine) c = 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, e.clientX || null, e.clientY || null, 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 ripple ----------

    // Displacement maps, one per frame: a packet of ripples whose front
    // travels from (cx, cy) out past the furthest corner. Red pushes the
    // picture sideways and green up and down, 128 meaning "leave it".
    function maps(w, h, cx, cy) {
      var mw = 160, mh = Math.max(40, Math.round(mw * h / w));
      var cv = document.createElement('canvas');
      cv.width = mw;
      cv.height = mh;
      var ctx = cv.getContext('2d'), img = ctx.createImageData(mw, mh), d = img.data;
      var far = reach(w, h, cx, cy), lam = Math.max(22, Math.min(w, h) * 0.085), out = [];
      for (var f = 0; f < FRAMES; f++) {
        var q = f / (FRAMES - 1), front = q * (far + lam * 3);
        var fade = Math.min(1, q * 6);                 // the ripple builds as it sets off
        for (var j = 0; j < mh; j++) {
          for (var i = 0; i < mw; i++) {
            var x = (i + 0.5) * w / mw - cx, y = (j + 0.5) * h / mh - cy, r = Math.hypot(x, y) || 1;
            var s = (r - front) / lam;
            // Strong ripples at the front, a gentle wake behind it.
            var env = Math.exp(-s * s * 0.45) + (s < 0 ? 0.3 * Math.exp(s * 0.5) : 0);
            // Calm right at the edges, so nothing is pulled in from outside the view.
            var px = (i + 0.5) * w / mw, py = (j + 0.5) * h / mh;
            var calm = Math.min(1, Math.min(px, py, w - px, h - py) / lam);
            var k = Math.sin(s * Math.PI * 2) * env * fade * calm;
            var p = (j * mw + i) * 4;
            d[p] = 128 + 127 * k * x / r;
            d[p + 1] = 128 + 127 * k * y / r;
            d[p + 2] = 128;
            d[p + 3] = 255;
          }
        }
        ctx.putImageData(img, 0, 0);
        out.push(cv.toDataURL());
      }
      return { urls: out, far: far, lam: lam };
    }

    var NS = 'http://www.w3.org/2000/svg';

    function containedMode(el, still) {
      el.classList.add('rpl-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var strength = el.dataset.strength != null ? +el.dataset.strength : DEFAULTS.strength;
      var interval = +el.dataset.interval || DEFAULTS.interval;
      var i = 0, busy = false, rest = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (still) return;
      // Safari can't bend HTML through an SVG filter, so there the views just
      // spread in as a growing circle with the sheen.
      var bend = !/^((?!chrome|android).)*safari/i.test(navigator.userAgent) && strength > 0;

      var defs = document.createElementNS(NS, 'svg');
      defs.setAttribute('class', 'rpl-defs');
      defs.setAttribute('aria-hidden', 'true');
      el.appendChild(defs);
      var sheen = document.createElement('div');
      sheen.className = 'rpl-sheen';
      sheen.setAttribute('aria-hidden', 'true');
      sheen.style.opacity = 0;
      el.appendChild(sheen);
      var id = 'rpl-f' + (++uid);

      function next(cx, cy) {
        if (busy || el.closest('[hidden]')) return;
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        busy = true;
        if (cx == null) { cx = w / 2; cy = h / 2; }
        var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
        var M = maps(w, h, cx, cy);
        var scale = Math.min(w, h) * 0.075 * strength;
        // One filter per frame, so each map is decoded once and swapping is instant.
        defs.innerHTML = '';
        var ready = M.urls.map(function (u, f) {
          var flt = document.createElementNS(NS, 'filter');
          flt.setAttribute('id', id + '-' + f);
          flt.setAttribute('filterUnits', 'userSpaceOnUse');
          flt.setAttribute('x', 0); flt.setAttribute('y', 0);
          flt.setAttribute('width', w); flt.setAttribute('height', h);
          flt.setAttribute('color-interpolation-filters', 'sRGB');
          var im = document.createElementNS(NS, 'feImage');
          im.setAttribute('href', u);
          im.setAttribute('x', 0); im.setAttribute('y', 0);
          im.setAttribute('width', w); im.setAttribute('height', h);
          im.setAttribute('preserveAspectRatio', 'none');
          im.setAttribute('result', 'm');
          var dm = document.createElementNS(NS, 'feDisplacementMap');
          dm.setAttribute('in', 'SourceGraphic');
          dm.setAttribute('in2', 'm');
          dm.setAttribute('scale', scale.toFixed(1));
          dm.setAttribute('xChannelSelector', 'R');
          dm.setAttribute('yChannelSelector', 'G');
          flt.appendChild(im);
          flt.appendChild(dm);
          defs.appendChild(flt);
          var pic = new Image();
          pic.src = u;
          return pic.decode ? pic.decode().catch(function () {}) : Promise.resolve();
        });
        Promise.all(ready).then(function () {
          to.style.visibility = 'visible';
          to.style.zIndex = 1;
          tween(duration, 'linear', function (t) {
            var q = t;
            var front = q * (M.far + M.lam * 3);
            var f = Math.min(FRAMES - 1, Math.round(q * (FRAMES - 1)));
            var fl = bend ? 'url(#' + id + '-' + f + ')' : '';
            from.style.filter = fl;
            to.style.filter = fl;
            // The next view fills in behind the leading ripple.
            var r = Math.max(0, front - M.lam * 0.6);
            to.style.clipPath = 'circle(' + r.toFixed(1) + 'px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
            // A soft band of light rides the ripples.
            sheen.style.opacity = Math.sin(Math.PI * Math.min(1, q * 1.1)).toFixed(3);
            sheen.style.background = 'radial-gradient(circle at ' + cx.toFixed(0) + 'px ' + cy.toFixed(0) + 'px,transparent ' + Math.max(0, front - M.lam * 2.2).toFixed(0) + 'px,rgba(255,255,255,.55) ' + Math.max(0, front - M.lam * 1.2).toFixed(0) + 'px,rgba(0,0,0,.35) ' + Math.max(0, front - M.lam * 0.4).toFixed(0) + 'px,rgba(255,255,255,.6) ' + (front + M.lam * 0.4).toFixed(0) + 'px,transparent ' + (front + M.lam * 1.4).toFixed(0) + 'px)';
          }, function () {
            from.style.visibility = 'hidden';
            from.style.filter = to.style.filter = to.style.clipPath = to.style.zIndex = '';
            sheen.style.opacity = 0;
            defs.innerHTML = '';
            i = n;
            busy = false;
          });
        });
      }

      var timer = anime.createTimer({
        duration: interval + duration,
        loop: true,
        autoplay: false,
        onLoop: function () { if (Date.now() > rest) next(); }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
      // Tap to drop a ripple where you touched.
      el.style.cursor = 'pointer';
      el.addEventListener('pointerdown', function (e) {
        var b = el.getBoundingClientRect();
        rest = Date.now() + interval * 2;
        next(e.clientX - b.left, e.clientY - b.top);
      });
    }

    function init() {
      var still = reduced();
      document.querySelectorAll('[data-ripple-transition]').forEach(function (el) {
        if (el._rpl) return;
        el._rpl = 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-ripple-transition>
  …
</body>