← Page transitions

Blob Morph Transition

A page transition: when a link is clicked, a wobbling liquid blob swells out from the spot you clicked until it fills the screen, and on the next page it shrinks back into that spot. 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-blob-morph-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-blob-morph-transition="#hex"Blob colour. Default #6a0dad.
data-durationTime for each grow or shrink. Default 800 ms.
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 shrinks the blob away again. Put data-blob-morph-transition on any other element with two or more children and it becomes a slideshow.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Blob Morph Transition: a page transition. When a link is clicked a
    // wobbling liquid blob grows from the spot you clicked until it fills the
    // screen; on the next page it shrinks away into that same spot.
    // Usage: <body data-blob-morph-transition>   (add the snippet to every page)
    // Optional: data-blob-morph-transition="#hex" (blob colour, default
    // #6a0dad), data-duration (ms for each half, default 800).
    // Contained mode: put data-blob-morph-transition on any other element with
    // two or more children and it blobs between them every data-interval ms
    // (default 2200).
    var DEFAULTS = { color: '#6a0dad', duration: 800, interval: 2200 };
    var KEY = 'animacious-blob-morph';

    var css =
      '.bmt-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;visibility:hidden;display:block}' +
      '.bmt-contained{position:relative;overflow:hidden;display:grid}' +
      '.bmt-contained>*:not(.bmt-layer){grid-area:1/1}' +
      '.bmt-contained>.bmt-layer{position:absolute;z-index:2}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function layer(parent, color) {
      var c = document.createElement('canvas');
      c.className = 'bmt-layer';
      c.setAttribute('aria-hidden', 'true');
      parent.appendChild(c);
      var L = { c: c, ctx: c.getContext('2d'), color: color, w: 0, h: 0 };
      fit(L);
      return L;
    }

    // Match the canvas to its box, sharp on high-density screens.
    function fit(L) {
      var w = L.c.clientWidth, h = L.c.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
      if (L.c.width !== Math.round(w * dpr) || L.c.height !== Math.round(h * dpr)) {
        L.c.width = Math.round(w * dpr);
        L.c.height = Math.round(h * dpr);
      }
      L.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      L.w = w;
      L.h = h;
    }

    // A closed, smooth blob through `n` points whose distance from the centre
    // drifts with time, so the edge keeps wobbling like liquid.
    function blob(ctx, x, y, r, time, seed) {
      var n = 9, pts = [], i;
      for (i = 0; i < n; i++) {
        var a = i / n * Math.PI * 2;
        var k = 1 + 0.09 * Math.sin(time * 2.1 + i * 1.7 + seed) + 0.06 * Math.sin(time * 3.3 - i * 2.3 + seed * 2);
        pts.push([x + Math.cos(a) * r * k, y + Math.sin(a) * r * k]);
      }
      ctx.beginPath();
      var last = pts[n - 1];
      ctx.moveTo((last[0] + pts[0][0]) / 2, (last[1] + pts[0][1]) / 2);
      for (i = 0; i < n; i++) {
        var p = pts[i], q = pts[(i + 1) % n];
        ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2);
      }
      ctx.fill();
    }

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

    // grow: 0 = nothing, 1 = the whole box covered.
    function paint(L, grow, fx, fy) {
      var ctx = L.ctx, w = L.w, h = L.h, x = fx * w, y = fy * h;
      // Far enough to reach every corner even at the blob's narrowest wobble.
      var far = Math.max(Math.hypot(x, y), Math.hypot(w - x, y), Math.hypot(x, h - y), Math.hypot(w - x, h - y)) / 0.82;
      var time = performance.now() / 1000;
      ctx.clearRect(0, 0, w, h);
      if (grow <= 0) return;
      ctx.fillStyle = L.color;
      // A paler blob runs slightly ahead, like a meniscus.
      ctx.globalAlpha = 0.35;
      blob(ctx, x, y, far * Math.min(1, grow * 1.12), time, 4);
      ctx.globalAlpha = 1;
      blob(ctx, x, y, far * grow, time, 0);
    }

    // One half: cover (blob grows) or uncover (blob shrinks away).
    function run(L, cover, fx, fy, duration, done) {
      if (L.anim) L.anim.pause();
      fit(L);
      var s = { t: 0 };
      L.c.style.visibility = 'visible';
      paint(L, cover ? 0 : 1, fx, fy);
      L.anim = anime.animate(s, {
        t: 1,
        duration: duration,
        ease: 'linear',
        onUpdate: function () { var e = ease(s.t); paint(L, cover ? e : 1 - e, fx, fy); },
        onComplete: function () {
          paint(L, cover ? 1 : 0, fx, fy);
          if (!cover) L.c.style.visibility = 'hidden';
          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;
    }

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var L = layer(body, body.getAttribute('data-blob-morph-transition') || DEFAULTS.color);
      var trip = 0, at = null;
      try { at = JSON.parse(sessionStorage.getItem(KEY)); sessionStorage.removeItem(KEY); } catch (e) { /* storage blocked */ }
      at = at || [0.5, 0.5];
      run(L, false, at[0], at[1], duration);

      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; };
        var fx = e.clientX / innerWidth || 0.5, fy = e.clientY / innerHeight || 0.5;
        try { sessionStorage.setItem(KEY, JSON.stringify([fx, fy])); } catch (err) { /* ignore */ }
        try {
          run(L, true, fx, fy, duration, go);
          // Never hold up the visit.
          setTimeout(go, duration + 400);
        } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        run(L, false, 0.5, 0.5, duration);
      });
    }

    function containedMode(el, reduced) {
      el.classList.add('bmt-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var L = layer(el, el.getAttribute('data-blob-morph-transition') || DEFAULTS.color);
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) return;
      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2 + 150,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          // Somewhere new each time, not too near the edge.
          var fx = 0.25 + Math.random() * 0.5, fy = 0.25 + Math.random() * 0.5;
          run(L, true, fx, fy, duration, function () {
            slides[i].style.visibility = 'hidden';
            i = (i + 1) % slides.length;
            slides[i].style.visibility = 'visible';
            setTimeout(function () { run(L, false, fx, fy, duration); }, 150);
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
      new ResizeObserver(function () { if (L.c.style.visibility === 'visible') fit(L); }).observe(el);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-blob-morph-transition]').forEach(function (el) {
        if (el._bmt) return;
        el._bmt = true;
        if (el === document.body) { if (!reduced) pageMode(el); }
        else if (el.children.length > 1) containedMode(el, reduced);
      });
    }

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