← Page transitions

Light Leak Transition

A warm light leak flares across the change, like light spilling into an old film camera: glowing blobs of orange, gold and red drift over the view, wash it out to a warm glare, and the next view comes up out of the glow. As a page transition the page flares out when a link is clicked and the next page appears as the glare drifts 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-light-leak-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-light-leak-transition="#hex"The main colour of the leak. Default #ff7a1a.
data-directionWhich way the leak travels: right or left. Default right.
data-durationTime for the whole flare. Default 1100 ms on a page (half leaving, half arriving), 1300 ms in a slideshow.
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 lets the glare drift off again. Put data-light-leak-transition on any other element with two or more children and it flares 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 () {
    // Light Leak Transition: a warm light leak flares across the change, like
    // light spilling into an old film camera. Glowing blobs of orange, gold and
    // red sweep over the view, wash it out to a warm glare, and the next view
    // comes up out of the glow as they drift off the other side.
    // Usage: <body data-light-leak-transition>   (add the snippet to every page)
    // Optional: data-light-leak-transition="#hex" (the main colour of the leak,
    // default #ff7a1a), data-direction ("right", the default: the leak travels
    // left to right; or "left"), data-duration (ms for the whole flare:
    // default 1100 on a page, split across leaving and arriving; 1300 for a
    // slideshow change).
    // Contained mode: put data-light-leak-transition on any other element with
    // two or more children. Every data-interval ms (default 2200) the leak
    // flares across and the next child appears.
    var DEFAULTS = { color: '#ff7a1a', direction: 'right', duration: 1100, slideDuration: 1300, interval: 2200 };
    var KEY = 'animacious-light-leak';

    var css =
      'html.llt-pre body{opacity:0}' +
      '.llt-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
      '.llt-contained{position:relative;overflow:hidden;display:grid}' +
      '.llt-contained>*{grid-area:1/1}' +
      '.llt-contained>.llt-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
      '.llt-wash,.llt-glow{position:absolute;inset:0}' +
      '.llt-glow{mix-blend-mode:screen}' +
      '.llt-blob{position:absolute;left:0;top:0;border-radius:50%;will-change:transform,opacity}' +
      '.llt-streak{position:absolute;left:0;top:0;border-radius:50%;will-change:transform,opacity}';
    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('llt-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('llt-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 rgba(hex, a) {
      var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
      if (!m) return 'rgba(255,122,26,' + a + ')';
      var h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
      var n = parseInt(h, 16);
      return 'rgba(' + (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255) + ',' + a + ')';
    }

    // The leak itself: a warm wash plus glowing blobs and a thin anamorphic
    // streak that drift across. Returns paint(t) for t from 0 to 1, with the
    // glare at its brightest at t = 0.5. `full` makes the glare opaque at the
    // peak (needed on a page, so the swap can't be seen).
    function leak(parent, color, dir, full) {
      var fx = document.createElement('div');
      fx.className = parent === document.body ? 'llt-layer' : 'llt-fx';
      fx.setAttribute('aria-hidden', 'true');
      var wash = document.createElement('div');
      wash.className = 'llt-wash';
      wash.style.background = 'radial-gradient(120% 90% at 50% 45%,#fff6e8 0%,' + rgba(color, 1) + ' 120%)';
      var glow = document.createElement('div');
      glow.className = 'llt-glow';
      fx.appendChild(wash);
      fx.appendChild(glow);
      // [colour, size (x the longer side), height in the view (0-1), speed, lag]
      var spec = [
        [rgba(color, 0.95), 1.05, 0.38, 1.0, 0],
        ['rgba(255,209,102,0.9)', 0.7, 0.72, 1.25, 0.08],
        ['rgba(255,61,110,0.75)', 0.85, 0.12, 0.85, 0.04],
        ['rgba(255,236,190,0.9)', 0.45, 0.55, 1.4, 0.12]
      ];
      var blobs = spec.map(function (s) {
        var b = document.createElement('i');
        b.className = 'llt-blob';
        b.style.background = 'radial-gradient(circle,' + s[0] + ' 0%,' + s[0].replace(/[\d.]+\)$/, '0.35)') + ' 38%,transparent 70%)';
        glow.appendChild(b);
        return { el: b, s: s };
      });
      var streak = document.createElement('i');
      streak.className = 'llt-streak';
      streak.style.background = 'radial-gradient(closest-side,rgba(255,250,235,.95),rgba(255,190,120,.4) 45%,transparent)';
      glow.appendChild(streak);
      parent.appendChild(fx);

      return {
        fx: fx,
        paint: function (t) {
          var w = fx.clientWidth || innerWidth, h = fx.clientHeight || innerHeight;
          var big = Math.max(w, h);
          var sign = dir === 'left' ? -1 : 1;
          var bell = Math.sin(Math.PI * Math.min(1, Math.max(0, t)));
          blobs.forEach(function (b) {
            var d = b.s[1] * big;
            // Each blob travels from off one edge to off the other, at its own pace.
            var p = Math.min(1, Math.max(0, (t - b.s[4]) * b.s[3] / (1 - b.s[4] * 0.5)));
            var x = -d + (w + d) * p;
            if (sign < 0) x = w - x - d;
            var y = b.s[2] * h - d / 2 + Math.sin(p * Math.PI * 1.3) * h * 0.08;
            b.el.style.width = b.el.style.height = d.toFixed(0) + 'px';
            b.el.style.transform = 'translate(' + (x).toFixed(1) + 'px,' + y.toFixed(1) + 'px) scale(' + (0.8 + 0.4 * bell).toFixed(3) + ')';
            b.el.style.opacity = Math.pow(bell, 0.6).toFixed(3);
          });
          var sw = w * 1.6, sh = Math.max(10, h * 0.06);
          var sx = (sign > 0 ? -sw + (w + sw) * t : w - (w + sw) * t * 1.0);
          streak.style.width = sw.toFixed(0) + 'px';
          streak.style.height = sh.toFixed(0) + 'px';
          streak.style.transform = 'translate(' + sx.toFixed(1) + 'px,' + (h * 0.42 - sh / 2).toFixed(1) + 'px)';
          streak.style.opacity = (bell * 0.9).toFixed(3);
          // The wash: a quick bloom round the middle of the flare.
          var g = Math.exp(-Math.pow((t - 0.5) / (full ? 0.2 : 0.13), 2));
          wash.style.opacity = (full ? Math.min(1, g * 1.08) : g * 0.94).toFixed(3);
        }
      };
    }

    // ---------- Page mode: the leak flares up, the page changes in the glare ----------

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var color = body.getAttribute('data-light-leak-transition') || DEFAULTS.color;
      var dir = body.dataset.direction || DEFAULTS.direction;
      var L = null, anim, trip = 0;

      // Leaving plays the first half of the flare, up to the glare; arriving
      // plays the second half, out of it.
      function run(out, done) {
        if (anim) anim.pause();
        if (L) L.fx.remove();
        L = leak(body, color, dir, true);
        anim = tween(duration / 2, out ? 'in(1.6)' : 'out(1.6)', function (t) { L.paint(out ? t / 2 : 0.5 + t / 2); }, function () {
          if (!out) { L.fx.remove(); L = 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 / 2 + 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 leak flares across a slideshow ----------

    function containedMode(el, still) {
      el.classList.add('llt-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.slideDuration;
      var color = el.getAttribute('data-light-leak-transition') || DEFAULTS.color;
      var dir = el.dataset.direction || DEFAULTS.direction;
      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 n = (i + 1) % slides.length, from = slides[i], to = slides[n], cut = false;
          var L = leak(el, color, dir, false);
          tween(duration, 'inOut(1.4)', function (t) {
            L.paint(t);
            if (!cut && t >= 0.5) {
              cut = true;
              from.style.visibility = 'hidden';
              from.style.filter = '';
              to.style.visibility = 'visible';
            }
            // The view under the leak warms up and burns out a little.
            var k = Math.exp(-Math.pow((t - 0.5) / 0.2, 2));
            (cut ? to : from).style.filter = k > 0.01 ? 'brightness(' + (1 + 0.5 * k).toFixed(3) + ') sepia(' + (0.45 * k).toFixed(3) + ') saturate(' + (1 + 0.4 * k).toFixed(3) + ')' : '';
          }, function () {
            to.style.filter = '';
            L.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-light-leak-transition]').forEach(function (el) {
        if (el._llt) return;
        el._llt = 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-light-leak-transition>
  …
</body>