← Page transitions

Tile Flip Transition

A page transition: when a link is clicked the screen breaks into a grid of tiles that flip over in a wave from the spot you clicked, and on the next page they flip away again from the same 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-tile-flip-transition to each page’s <body>.

Options

Add any of these attributes to the same element.

data-tile-flip-transition="…"Tile colour, or any CSS background such as a gradient. Default a purple gradient.
data-sizeRough tile size. Default 120 px.
data-durationTime for each tile to flip. Default 480 ms.
data-intervalContained mode only: time each slide stays. Default 2000 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 flips the tiles away. Put data-tile-flip-transition on any other element with two or more children and it becomes a slideshow whose tiles carry real pieces of both slides, like the demo above.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tile Flip Transition: a page transition. When a link is clicked the
    // screen breaks into a grid of tiles that flip over in a wave from the
    // spot you clicked; on the next page they flip away again from that spot.
    // Usage: <body data-tile-flip-transition>   (add the snippet to every page)
    // Optional: data-tile-flip-transition="#hex" (tile colour or any CSS
    // background, default a purple gradient), data-size (rough tile size in
    // px, default 120), data-duration (ms for each tile, default 480).
    // Contained mode: put data-tile-flip-transition on any other element with
    // two or more children. Every data-interval ms its tiles flip over, each
    // carrying its piece of the current child on the front and the next on the back.
    var DEFAULTS = { color: 'linear-gradient(135deg, #a855f7, #6a0dad)', size: 120, duration: 480, interval: 2000 };
    var KEY = 'animacious-tile-flip';

    var css =
      '.tft-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:hidden;perspective:1600px}' +
      '.tft-tile{position:absolute;backface-visibility:hidden}' +
      '.tft-contained{position:relative;overflow:hidden;display:grid}' +
      '.tft-contained>*:not(.tft-layer){grid-area:1/1}' +
      '.tft-contained>.tft-layer{position:absolute;visibility:visible;perspective:900px}' +
      '.tft-card{position:absolute;transform-style:preserve-3d}' +
      '.tft-face{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.tft-face>*{position:absolute!important;margin:0!important;transform:none!important;visibility:visible!important}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Each tile's delay grows with its distance from the starting point, so
    // the flip spreads out in a wave over `spread` ms.
    function delays(cols, rows, fx, fy, spread) {
      var out = [], max = 0, c, r;
      for (r = 0; r < rows; r++) for (c = 0; c < cols; c++) {
        var d = Math.hypot((c + 0.5) / cols - fx, ((r + 0.5) / rows - fy) * rows / cols);
        out.push(d);
        if (d > max) max = d;
      }
      return out.map(function (d) { return max ? d / max * spread : 0; });
    }

    // ---------- Page mode: tiles of colour cover the page and uncover the next ----------

    function layer(body, bg, size) {
      var l = document.createElement('div');
      l.className = 'tft-layer';
      l.setAttribute('aria-hidden', 'true');
      body.appendChild(l);
      l._tftBg = bg;
      l._tftSize = size;
      return l;
    }

    // (Re)build the grid to fit the window, one tile per cell, each showing its
    // own slice of one big background so a gradient runs across them all.
    function tiles(l) {
      var cols = Math.max(2, Math.round(innerWidth / l._tftSize)), rows = Math.max(2, Math.round(innerHeight / l._tftSize));
      if (l._tftCols !== cols || l._tftRows !== rows) {
        l.textContent = '';
        for (var r = 0; r < rows; r++) for (var c = 0; c < cols; c++) {
          var t = document.createElement('div');
          t.className = 'tft-tile';
          t.style.cssText = 'left:' + c * 100 / cols + '%;top:' + r * 100 / rows + '%;' +
            'width:calc(' + 100 / cols + '% + 1px);height:calc(' + 100 / rows + '% + 1px);' +
            'background:' + l._tftBg + ';background-size:' + cols * 100 + '% ' + rows * 100 + '%;' +
            'background-position:' + c / (cols - 1) * 100 + '% ' + r / (rows - 1) * 100 + '%';
          l.appendChild(t);
        }
        l._tftCols = cols;
        l._tftRows = rows;
      }
      return Array.from(l.children);
    }

    // Cover: tiles flip in to face us. Uncover: they carry on round and away.
    function flip(l, cover, fx, fy, duration, done) {
      if (l._tft) l._tft.pause();
      var ts = tiles(l);
      var wait = delays(l._tftCols, l._tftRows, fx, fy, duration * 0.9);
      anime.utils.set(ts, cover ? { rotateY: 180, scale: 0.6 } : { rotateY: 0, scale: 1 });
      l.style.visibility = 'visible';
      var tl = anime.createTimeline({
        onComplete: function () { if (!cover) l.style.visibility = 'hidden'; if (done) done(); }
      });
      tl.add(ts, {
        rotateY: cover ? 0 : -180,
        scale: cover ? 1 : 0.6,
        duration: duration,
        ease: cover ? 'out(3)' : 'in(2)',
        delay: function (t, i) { return wait[i]; }
      });
      l._tft = tl;
      return duration * 1.9;
    }

    // Same-site links that should get the transition: not new tabs, downloads,
    // other sites or #anchors on this page.
    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-tile-flip-transition') || DEFAULTS.color, +body.dataset.size || DEFAULTS.size);
      var trip = 0, at = null;
      // Uncover from where the visitor clicked on the last page, if we know it.
      try { at = JSON.parse(sessionStorage.getItem(KEY)); sessionStorage.removeItem(KEY); } catch (e) { /* storage blocked */ }
      if (!at) at = [0.5, 0.5];
      flip(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; };
        at = [e.clientX / innerWidth, e.clientY / innerHeight];
        try { sessionStorage.setItem(KEY, JSON.stringify(at)); } catch (err) { /* ignore */ }
        try {
          var total = flip(l, true, at[0], at[1], duration, go);
          // Never hold up the visit: go anyway if the tiles stall.
          setTimeout(go, total + 400);
        } catch (err) { go(); }
      });
      // Coming back with the Back button can restore the covered page: flip the
      // tiles away, and cancel the visit that page was on its way to.
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        try { sessionStorage.removeItem(KEY); } catch (err) { /* ignore */ }
        flip(l, false, at[0], at[1], duration);
      });
    }

    // ---------- Contained mode: tiles carry real pieces of both slides ----------

    // How a slide is drawn on a tile. Pictures (an <img> or inline <svg>)
    // become one background image that each tile shows its own part of;
    // anything else is copied into the tile.
    function picture(slide, w, h) {
      var tag = slide.tagName.toLowerCase();
      if (tag === 'svg') {
        var copy = slide.cloneNode(true);
        copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        copy.setAttribute('width', w);
        copy.setAttribute('height', h);
        copy.removeAttribute('style');
        var url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml' }));
        return { url: url, x: 0, y: 0, w: w, h: h, free: true };
      }
      if (tag === 'img' && slide.naturalWidth) {
        // object-fit: cover, centred.
        var k = Math.max(w / slide.naturalWidth, h / slide.naturalHeight);
        var iw = slide.naturalWidth * k, ih = slide.naturalHeight * k;
        return { url: slide.currentSrc || slide.src, x: (w - iw) / 2, y: (h - ih) / 2, w: iw, h: ih };
      }
      return null;
    }

    function fill(face, slide, pic, x, y, w, h) {
      if (pic) {
        face.style.background = 'url("' + pic.url + '") ' + (pic.x - x) + 'px ' + (pic.y - y) + 'px / ' + pic.w + 'px ' + pic.h + 'px no-repeat';
        return;
      }
      var c = slide.cloneNode(true);
      c.removeAttribute('id');
      c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      c.setAttribute('aria-hidden', 'true');
      c.style.left = -x + 'px';
      c.style.top = -y + 'px';
      c.style.width = w + 'px';
      c.style.height = h + 'px';
      face.style.overflow = 'hidden';
      face.appendChild(c);
    }

    function containedMode(el, reduced) {
      el.classList.add('tft-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var size = +el.dataset.size || 0;
      var i = 0, busy = false;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) return;

      function next() {
        if (busy || el.closest('[hidden]')) return;
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return;
        busy = true;
        var from = slides[i], to = slides[(i + 1) % slides.length];
        var picFrom = picture(from, w, h), picTo = picture(to, w, h);
        // Wait for both pictures to be ready, so no tile ever shows up blank.
        Promise.all([picFrom, picTo].map(function (p) {
          return new Promise(function (done) {
            if (!p) return done();
            var img = new Image();
            img.onload = img.onerror = function () { done(); };
            img.src = p.url;
          });
        })).then(function () { flipTo(from, to, picFrom, picTo, w, h); });
      }

      function flipTo(from, to, picFrom, picTo, w, h) {
        var s = size || Math.max(48, Math.min(w, h) / 4);
        var cols = Math.max(2, Math.round(w / s)), rows = Math.max(2, Math.round(h / s));
        var tw = w / cols, th = h / rows;
        var l = document.createElement('div');
        l.className = 'tft-layer';
        l.setAttribute('aria-hidden', 'true');
        var cards = [];
        for (var r = 0; r < rows; r++) for (var c = 0; c < cols; c++) {
          var x = Math.round(c * tw), y = Math.round(r * th);
          var cw = Math.round((c + 1) * tw) - x, ch = Math.round((r + 1) * th) - y;
          var card = document.createElement('div');
          card.className = 'tft-card';
          // A pixel of overlap hides the seams between tiles that haven't turned yet.
          card.style.cssText = 'left:' + x + 'px;top:' + y + 'px;width:' + (cw + 1) + 'px;height:' + (ch + 1) + 'px';
          var front = document.createElement('div'), back = document.createElement('div');
          front.className = back.className = 'tft-face';
          back.style.transform = 'rotateY(180deg)';
          fill(front, from, picFrom, x, y, w, h);
          fill(back, to, picTo, x, y, w, h);
          card.appendChild(front);
          card.appendChild(back);
          l.appendChild(card);
          cards.push(card);
        }
        el.appendChild(l);
        from.style.visibility = 'hidden';
        // A wave from a random corner, each tile lifting a little as it turns.
        var corner = [[0, 0], [1, 0], [0, 1], [1, 1]][Math.floor(Math.random() * 4)];
        var wait = delays(cols, rows, corner[0], corner[1], duration * 1.4);
        var tl = anime.createTimeline({
          onComplete: function () {
            to.style.visibility = 'visible';
            l.remove();
            [picFrom, picTo].forEach(function (p) { if (p && p.free) URL.revokeObjectURL(p.url); });
            i = (i + 1) % slides.length;
            busy = false;
          }
        });
        tl.add(cards, {
          rotateY: [0, 180],
          scale: [1, 0.86, 1],
          duration: duration,
          ease: 'inOut(2)',
          delay: function (t, n) { return wait[n]; }
        });
      }

      var timer = anime.createTimer({ duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2.4, loop: true, autoplay: false, onLoop: next });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-tile-flip-transition]').forEach(function (el) {
        if (el._tftOn) return;
        el._tftOn = 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-tile-flip-transition>
  …
</body>