← Page transitions

Drip Wipe

A page transition: when a link is clicked, gooey drips run down from the top of the screen, fattening and joining until the goo covers everything. On the next page the goo slides off the bottom, its sticky runs clinging on for a moment before they slip away. 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-drip-wipe to each page’s <body>.

Options

Add any of these attributes to the same element.

data-drip-wipe="#hex"Goo colour. Default #6a0dad.
data-durationTime for the drips to cover the screen, and again to slide away. Default 800 ms.
data-intervalContained mode only: time each slide stays. Default 2200 ms.

Every change gets a fresh set of drips. 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 slides the goo away again. Put data-drip-wipe 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 () {
    // Drip Wipe: gooey drips run down from the top edge, thicken and join until
    // they cover the screen, then the goo slides off the bottom, leaving sticky
    // runs that slip away last, to show the next view.
    // Usage: <body data-drip-wipe>   (add the snippet to every page)
    // Optional: data-drip-wipe="#hex" (goo colour, default #6a0dad),
    // data-duration (ms for the drips to cover, and again to slide away,
    // default 800).
    // Contained mode: put data-drip-wipe on any other element with two or more
    // children and it drips between them every data-interval ms (default 2200).
    var DEFAULTS = { color: '#6a0dad', duration: 800, interval: 2200 };
    var KEY = 'animacious-drip-wipe';

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

    function rand(a, b) { return a + Math.random() * (b - a); }

    // A canvas over `parent`, sized to it and sharp on high-density screens.
    function layer(parent, color) {
      var c = document.createElement('canvas');
      c.className = 'drw-layer';
      c.setAttribute('aria-hidden', 'true');
      parent.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);
      // A fresh set of drips each time, spaced across the width.
      var unit = Math.max(0.6, Math.min(1.6, Math.min(w, h) / 420));
      var n = Math.max(6, Math.round(w / (46 * unit))), drips = [];
      for (var i = 0; i < n; i++) {
        drips.push({
          x: (i + rand(0.2, 0.8)) / n * w,
          r: rand(6, 19) * unit,           // half the width of the run
          len: rand(0.18, 0.62),           // how far it runs ahead, x the height
          speed: rand(0.9, 1.7),
          lag: rand(0.25, 1)               // how long it clings when sliding away
        });
      }
      return { c: c, ctx: ctx, w: w, h: h, color: color, drips: drips, unit: unit };
    }

    // One run of goo hanging from the edge at y = edge. `sign` 1 hangs down,
    // -1 hangs up (a sticky run left clinging behind as the goo slides off).
    function run(ctx, x, edge, r, len, sign) {
      if (len <= 0.5) return;
      var f = Math.min(r * 2.2, len);              // the wide fillet where it leaves the edge
      var neck = r * 0.72;                          // the run thins behind its bead
      var bead = r * 1.12;
      var tip = edge + sign * Math.max(len - bead, f * 0.5);
      ctx.beginPath();
      ctx.moveTo(x - r - f * 1.4, edge - sign);
      ctx.bezierCurveTo(x - r, edge, x - neck, edge + sign * f * 0.4, x - neck, edge + sign * f);
      ctx.lineTo(x - neck, tip - sign * bead * 0.9);
      // A rounded, fatter bead at the end, the way a heavy drop gathers.
      ctx.quadraticCurveTo(x - bead, tip - sign * bead * 0.5, x - bead, tip);
      ctx.arc(x, tip, bead, Math.PI, 0, sign > 0);
      ctx.quadraticCurveTo(x + bead, tip - sign * bead * 0.5, x + neck, tip - sign * bead * 0.9);
      ctx.lineTo(x + neck, edge + sign * f);
      ctx.bezierCurveTo(x + neck, edge + sign * f * 0.4, x + r, edge, x + r + f * 1.4, edge - sign);
      ctx.closePath();
      ctx.fill();
    }

    function shine(ctx, x, edge, r, len, sign) {
      if (len < r * 2) return;
      var tip = edge + sign * (len - r);
      ctx.beginPath();
      ctx.moveTo(x - r * 0.42, tip - sign * Math.min(len * 0.45, r * 3));
      ctx.lineTo(x - r * 0.42, tip);
      ctx.stroke();
    }

    // Covering: t 0 to 1 runs the drips down until the goo fills the screen.
    function paintCover(L, t) {
      var ctx = L.ctx, w = L.w, h = L.h;
      ctx.clearRect(0, 0, w, h);
      if (t <= 0) return;
      ctx.fillStyle = L.color;
      // The main sheet follows behind the drips and fills the screen at the end.
      var edge = (h * 1.04 + 20) * Math.pow(t, 1.7) - 1;
      var wob = 3 * L.unit;
      ctx.beginPath();
      ctx.moveTo(0, -1);
      ctx.lineTo(w, -1);
      for (var x = w; x >= -10; x -= 10) ctx.lineTo(x, edge + Math.sin(x * 0.03 + t * 6) * wob);
      ctx.closePath();
      ctx.fill();
      L.drips.forEach(function (d) {
        var p = Math.min(1, t * d.speed * 1.25);
        run(ctx, d.x, edge, d.r * (0.7 + 0.5 * t), h * d.len * (1 - Math.pow(1 - p, 2)) + 4, 1);
      });
      ctx.strokeStyle = 'rgba(255,255,255,.28)';
      ctx.lineWidth = 2.2 * L.unit;
      ctx.lineCap = 'round';
      L.drips.forEach(function (d) {
        var p = Math.min(1, t * d.speed * 1.25);
        shine(ctx, d.x, edge, d.r * (0.7 + 0.5 * t), h * d.len * (1 - Math.pow(1 - p, 2)) + 4, 1);
      });
    }

    // Sliding away: t 0 to 1 slides the goo down and off the bottom, its
    // sticky runs clinging on behind it for a moment.
    function paintDrain(L, t) {
      var ctx = L.ctx, w = L.w, h = L.h;
      ctx.clearRect(0, 0, w, h);
      if (t >= 1) return;
      ctx.fillStyle = L.color;
      // The top edge sags in soft lumps where the goo clings, and slides down
      // and off the bottom.
      var reach = h * 0.12;
      var edge = (h + reach + 10) * Math.pow(t, 1.6);
      var cling = Math.sin(Math.PI * Math.min(1, t * 1.3));
      ctx.beginPath();
      ctx.moveTo(-2, h + 2);
      for (var x = -2; x <= w + 10; x += 6) {
        var y = edge;
        for (var k = 0; k < L.drips.length; k++) {
          var d = L.drips[k], u = (x - d.x) / (d.r * 3.2);
          y -= reach * d.lag * cling * Math.exp(-u * u);
        }
        ctx.lineTo(x, y);
      }
      ctx.lineTo(w + 10, h + 2);
      ctx.closePath();
      ctx.fill();
    }

    // ---------- Page mode: the goo runs down over the page between links ----------

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

      function start(out, done) {
        if (anim) anim.pause();
        if (L) L.c.remove();
        L = layer(body, color);
        anim = tween(duration, out ? 'in(1.2)' : 'inOut(2)', function (t) { if (out) paintCover(L, t); else paintDrain(L, t); }, function () {
          if (!out) { L.c.remove(); L = null; }
          if (done) done();
        });
      }
      if (arriving) start(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 { start(true, go); } catch (err) { go(); }
      });
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        try { sessionStorage.removeItem(KEY); } catch (err) {}
        start(false);
      });
    }

    // ---------- Contained mode: a slideshow that drips from one child to the next ----------

    function containedMode(el, still) {
      el.classList.add('drw-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var color = el.getAttribute('data-drip-wipe') || DEFAULTS.color;
      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 * 2 + 120,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]') || !el.clientWidth) return;
          var n = (i + 1) % slides.length;
          var L = layer(el, color);
          tween(duration, 'in(1.2)', function (t) { paintCover(L, t); }, function () {
            slides[i].style.visibility = 'hidden';
            slides[n].style.visibility = 'visible';
            i = n;
            setTimeout(function () {
              tween(duration, 'inOut(2)', function (t) { paintDrain(L, t); }, function () { L.c.remove(); });
            }, 120);
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

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