← Page transitions

Shatter Transition

The view cracks from a point, splits into triangular shards and they tumble away under gravity, uncovering the next view. As a page transition the shards fly up and lock together over the page when a link is clicked, then crack and fall 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-shatter-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-shatter-transition="#hex"Shard colour on a page change. Default #1e1b4b.
data-sizeRough shard size. Default 150 px.
data-durationTime for the shards to fall. Default 1100 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 cracks the glass away again. Put data-shatter-transition on any other element with two or more children and each shard carries a real piece of the old view, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Shatter Transition: the view breaks into shards that fall away. As a page
    // transition, shards of colour fly up and lock together to cover the page
    // when a link is clicked, and on the next page they crack apart and fall.
    // Usage: <body data-shatter-transition>   (add the snippet to every page)
    // Optional: data-shatter-transition="#hex" (shard colour on a page,
    // default #1e1b4b), data-size (rough shard size in px, default 150),
    // data-duration (ms for the shards to fall, default 1100).
    // Contained mode: put data-shatter-transition on any other element with two
    // or more children. Every data-interval ms (default 2200) the current child
    // cracks from a random point and falls away in pieces, uncovering the next.
    var DEFAULTS = { color: '#1e1b4b', size: 150, duration: 1100, interval: 2200 };
    var KEY = 'animacious-shatter';

    var css =
      'html.sht-pre body{opacity:0}' +
      '.sht-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
      '.sht-shard{position:absolute;overflow:hidden;will-change:transform}' +
      '.sht-shard>*{position:absolute!important;margin:0!important;max-width:none!important}' +
      '.sht-contained{position:relative;overflow:hidden;display:grid}' +
      '.sht-contained>*{grid-area:1/1}' +
      '.sht-contained>.sht-in{position:absolute;inset:0;z-index:2;pointer-events:none}';
    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('sht-pre'); }
    if (arriving && !reduced()) {
      document.documentElement.classList.add('sht-pre');
      setTimeout(unhide, 3000);
    }

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

    // Triangles covering a w x h box: a jittered grid, each cell cut along a
    // random diagonal. Edge points stay on the edges so nothing leaks.
    function triangles(w, h, size) {
      var cols = Math.max(3, Math.min(8, Math.round(w / size))), rows = Math.max(3, Math.min(6, Math.round(h / size)));
      var cw = w / cols, ch = h / rows, pts = [], tris = [];
      for (var r = 0; r <= rows; r++) {
        pts.push([]);
        for (var c = 0; c <= cols; c++) {
          var x = c * cw, y = r * ch;
          if (c > 0 && c < cols) x += rand(-0.35, 0.35) * cw;
          if (r > 0 && r < rows) y += rand(-0.35, 0.35) * ch;
          pts[r].push([x, y]);
        }
      }
      for (r = 0; r < rows; r++) for (c = 0; c < cols; c++) {
        var a = pts[r][c], b = pts[r][c + 1], d = pts[r + 1][c], e = pts[r + 1][c + 1];
        if (Math.random() < 0.5) tris.push([a, b, e], [a, e, d]);
        else tris.push([a, b, d], [b, e, d]);
      }
      return tris;
    }

    // Builds the shards in `layer`; fill(shard, left, top) puts the content in
    // each. Returns a painter for time `ms` along the fall.
    function shatter(layer, w, h, size, duration, fill) {
      var ix = w * rand(0.3, 0.7), iy = h * rand(0.3, 0.6);
      var far = Math.hypot(Math.max(ix, w - ix), Math.max(iy, h - iy));
      var spread = duration * 0.35, fall = duration * 0.65, crack = 140;
      var shards = triangles(w, h, size).map(function (t) {
        var x0 = Math.min(t[0][0], t[1][0], t[2][0]), y0 = Math.min(t[0][1], t[1][1], t[2][1]);
        var x1 = Math.max(t[0][0], t[1][0], t[2][0]), y1 = Math.max(t[0][1], t[1][1], t[2][1]);
        var cx = (t[0][0] + t[1][0] + t[2][0]) / 3, cy = (t[0][1] + t[1][1] + t[2][1]) / 3;
        var el = document.createElement('div');
        el.className = 'sht-shard';
        // One pixel of overlap hides hairline seams until the glass cracks.
        var l = Math.floor(x0) - 1, tp = Math.floor(y0) - 1, sw = Math.ceil(x1) - l + 1, sh = Math.ceil(y1) - tp + 1;
        el.style.cssText = 'left:' + l + 'px;top:' + tp + 'px;width:' + sw + 'px;height:' + sh + 'px;transform-origin:' + (cx - l) + 'px ' + (cy - tp) + 'px;' +
          'clip-path:polygon(' + t.map(function (p) {
            // Push each corner a little away from the centre: a slight overlap.
            var dx = p[0] - cx, dy = p[1] - cy, k = 1 + 0.9 / Math.max(1, Math.hypot(dx, dy));
            return (cx + dx * k - l).toFixed(1) + 'px ' + (cy + dy * k - tp).toFixed(1) + 'px';
          }).join(',') + ')';
        fill(el, l, tp);
        layer.appendChild(el);
        var dx = cx - ix, dy = cy - iy, d = Math.hypot(dx, dy) || 1;
        return {
          el: el,
          delay: crack + spread * (d / far) + rand(0, 60),
          push: 3 + 5 * (1 - d / far), ux: dx / d, uy: dy / d,
          vx: dx / d * rand(20, 80), fallY: h * rand(1.05, 1.4),
          rz: rand(-70, 70), rx: rand(-50, 50)
        };
      });
      var total = crack + spread + fall + 60;
      function paint(ms) {
        for (var i = 0; i < shards.length; i++) {
          var s = shards[i], p = Math.max(0, Math.min(1, (ms - s.delay) / fall));
          var open = s.push * Math.min(1, ms / crack);
          var x = s.ux * open + s.vx * p, y = s.uy * open + s.fallY * p * p;
          s.el.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) perspective(600px) rotateX(' + (s.rx * p).toFixed(1) + 'deg) rotate(' + (s.rz * p).toFixed(1) + 'deg) scale(' + (1 - 0.15 * p).toFixed(3) + ')';
          s.el.style.opacity = p > 0.75 ? ((1 - p) / 0.25).toFixed(3) : '';
        }
      }
      return { paint: paint, total: total };
    }

    function play(total, paint, reverse, done) {
      var s = { ms: 0 };
      paint(reverse ? total : 0);
      return anime.animate(s, {
        ms: total, duration: total, ease: 'linear',
        onUpdate: function () { paint(reverse ? total - s.ms : s.ms); },
        onComplete: function () { paint(reverse ? 0 : total); 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 size = +body.dataset.size || DEFAULTS.size;
      var color = body.getAttribute('data-shatter-transition') || DEFAULTS.color;
      var layer = null, anim, trip = 0;

      // Leaving: the fall plays backwards, so the shards fly up and lock
      // together. Arriving: the glass cracks and falls away.
      function run(out, done) {
        if (anim) anim.pause();
        if (layer) layer.remove();
        layer = document.createElement('div');
        layer.className = 'sht-layer';
        layer.setAttribute('aria-hidden', 'true');
        var glass = shatter(layer, innerWidth, innerHeight, size, duration, function (el) {
          el.style.background = 'linear-gradient(' + Math.round(rand(0, 360)) + 'deg,rgba(255,255,255,' + rand(0, 0.12).toFixed(2) + '),rgba(0,0,0,' + rand(0, 0.15).toFixed(2) + ')),' + color;
        });
        body.appendChild(layer);
        anim = play(glass.total, glass.paint, out, function () {
          if (!out) { layer.remove(); layer = 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 + 700);
        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('sht-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var size = +el.dataset.size || DEFAULTS.size;
      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 + 300,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          var w = el.clientWidth, h = el.clientHeight;
          if (!w || !h) return;
          var from = slides[i], to = slides[(i + 1) % slides.length];
          var layer = document.createElement('div');
          layer.className = 'sht-in';
          layer.setAttribute('aria-hidden', 'true');
          // Each shard carries a copy of the old view, lined up with the original.
          var glass = shatter(layer, w, h, Math.min(size, Math.max(70, Math.min(w, h) / 3)), duration, function (shard, l, t) {
            var c = from.cloneNode(true);
            c.removeAttribute('id');
            c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
            c.style.left = -l + 'px';
            c.style.top = -t + 'px';
            c.style.width = w + 'px';
            c.style.height = h + 'px';
            c.style.visibility = 'visible';
            shard.appendChild(c);
          });
          el.appendChild(layer);
          to.style.visibility = 'visible';
          from.style.visibility = 'hidden';
          play(glass.total, glass.paint, false, function () { layer.remove(); });
          i = (i + 1) % slides.length;
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

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