← Cards & images

Tiled Image Reveal

The image is cut into a grid of tiles that turn over, pop or fade into place one after another, sweeping across from a corner or out from the centre until the whole picture is there.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tiled-image-reveal to a container holding an <img> or an inline <svg>, and give it a size or an aspect-ratio.

Options

Add any of these attributes to the same element.

data-effectflip, fade or zoom. A list like flip,zoom,fade takes turns each time it loops. Default flip.
data-colsTiles across. Default 6.
data-rowsTiles down. Default 4.
data-fromWhere the sweep starts: first (top left), center, last or random. Default first.
data-durationTime for each tile. Default 700 ms.
data-staggerGap between neighbouring tiles. Default 55 ms.
data-loopTile away and back in again, every few seconds.
data-holdHow long the finished picture rests before looping. Default 2600 ms.

Each tile shows its own slice of a copy of the picture, laid out in percentages, so it stays lined up at any size. The real image takes over once every tile is in. From code, animacious.tiledReveal(element) plays it again.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tiled Image Reveal: an image is cut into a grid of tiles that flip, fade
    // or pop in one after another, sweeping across from a corner or the centre,
    // until the whole picture is there.
    // Usage: <div data-tiled-image-reveal style="aspect-ratio: 16 / 9"><img src="photo.jpg" alt="…"></div>
    // The first child (an <img>, <svg> or any element) is the picture; give the
    // container a size or an aspect-ratio.
    // Optional: data-cols (default 6), data-rows (default 4), data-effect
    // ("flip", default, "fade" or "zoom"; a comma list like "flip,zoom,fade"
    // takes turns each loop), data-from ("first", default, "center", "last" or
    // "random"), data-duration (ms per tile, default 700), data-stagger (ms
    // between neighbouring tiles, default 55), data-loop (hide and reveal again).
    // Script use: animacious.tiledReveal(el) plays it again.
    var DEFAULTS = { cols: 6, rows: 4, duration: 700, stagger: 55, hold: 2600 };

    var css =
      '.tir{position:relative;overflow:hidden}' +
      '.tir>.tir-pic{display:block;width:100%;height:100%;object-fit:cover}' +
      '.tir-hide>.tir-pic{opacity:0}' +
      '.tir-tiles{position:absolute;inset:0;pointer-events:none;perspective:900px}' +
      '.tir-tile{position:absolute;overflow:hidden;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.tir-tile>*{position:absolute;top:0;display:block;max-width:none;object-fit:cover}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function stripIds(node) {
      if (node.removeAttribute) {
        node.removeAttribute('id');
        node.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
      }
      return node;
    }

    function setup(el) {
      var d = el.dataset;
      var pic = el.firstElementChild;
      if (!pic) return false;
      var cols = Math.max(1, +d.cols || DEFAULTS.cols);
      var rows = Math.max(1, +d.rows || DEFAULTS.rows);
      el.classList.add('tir');
      pic.classList.add('tir-pic');

      // Each tile holds its own copy of the picture, shifted so the tiles line up.
      // Percentages keep it right at any size, with no resize handling.
      var layer = document.createElement('div');
      layer.className = 'tir-tiles';
      layer.setAttribute('aria-hidden', 'true');
      var tiles = [];
      for (var r = 0; r < rows; r++) {
        for (var c = 0; c < cols; c++) {
          var t = document.createElement('div');
          t.className = 'tir-tile';
          // A hair of overlap hides seams between neighbouring tiles.
          t.style.cssText = 'left:' + (c / cols * 100) + '%;top:' + (r / rows * 100) + '%;width:calc(' + (100 / cols) + '% + 1px);height:calc(' + (100 / rows) + '% + 1px)';
          var copy = stripIds(pic.cloneNode(true));
          copy.classList.remove('tir-pic');
          copy.removeAttribute('alt');
          copy.style.cssText = 'left:calc(' + (-c * 100) + '% + ' + c + 'px);top:calc(' + (-r * 100) + '% + ' + r + 'px);width:calc(' + (cols * 100) + '% - ' + cols + 'px);height:calc(' + (rows * 100) + '% - ' + rows + 'px)';
          t.appendChild(copy);
          layer.appendChild(t);
          tiles.push(t);
        }
      }
      el.appendChild(layer);
      el._tirS = { tiles: tiles, layer: layer, cols: cols, rows: rows, turn: 0, tl: null, timer: 0 };
      hideTiles(el);
      return true;
    }

    function effects(el) {
      return (el.dataset.effect || 'flip').split(',').map(function (s) { return s.trim(); });
    }

    // Hidden pose of a tile for each effect.
    function hiddenPose(effect) {
      if (effect === 'fade') return { opacity: 0, scale: 1, rotateY: 0 };
      if (effect === 'zoom') return { opacity: 0, scale: 0.2, rotateY: 0 };
      return { opacity: 0, scale: 1, rotateY: -90 };
    }

    function hideTiles(el) {
      var s = el._tirS;
      var effect = effects(el)[s.turn % effects(el).length];
      anime.utils.set(s.tiles, hiddenPose(effect));
      s.layer.style.display = '';
      el.classList.add('tir-hide');
    }

    function play(el) {
      var s = el._tirS;
      if (!s) return;
      var d = el.dataset;
      if (s.tl) s.tl.pause();
      clearTimeout(s.timer);
      var list = effects(el);
      var effect = list[s.turn % list.length];
      var dur = +d.duration || DEFAULTS.duration;
      var gap = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var from = d.from || 'first';
      var order = anime.stagger(gap, { grid: [s.cols, s.rows], from: from === 'random' ? 'center' : from });
      if (from === 'random') {
        var delays = s.tiles.map(function () { return Math.random() * gap * (s.cols + s.rows); });
        order = function (t, i) { return delays[i]; };
      }
      hideTiles(el);
      var props = { opacity: 1, scale: 1, rotateY: 0, duration: dur, delay: order };
      if (effect === 'flip') { props.ease = 'out(3)'; props.opacity = { to: 1, duration: dur * 0.3 }; }
      else if (effect === 'zoom') props.ease = 'outBack(1.6)';
      else props.ease = 'out(2)';
      props.onComplete = function () {
        el.classList.remove('tir-hide');
        s.layer.style.display = 'none';
        if (d.loop === undefined || d.loop === 'false') return;
        s.timer = setTimeout(function () { leave(el); }, +d.hold || DEFAULTS.hold);
      };
      s.tl = anime.animate(s.tiles, props);
    }

    // Loop: the tiles go out the same way, then the next effect brings them back.
    function leave(el) {
      var s = el._tirS, d = el.dataset;
      var list = effects(el);
      var effect = list[s.turn % list.length];
      var gap = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      anime.utils.set(s.tiles, { opacity: 1, scale: 1, rotateY: 0 });
      s.layer.style.display = '';
      el.classList.add('tir-hide');
      var out = hiddenPose(effect);
      if (effect === 'flip') out.rotateY = 90;
      out.duration = (+d.duration || DEFAULTS.duration) * 0.7;
      out.ease = 'in(2)';
      out.delay = anime.stagger(gap * 0.6, { grid: [s.cols, s.rows], from: 'last' });
      out.onComplete = function () {
        s.turn++;
        s.timer = setTimeout(function () { if (s.visible !== false) play(el); else s.pending = true; }, 350);
      };
      s.tl = anime.animate(s.tiles, out);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._tirS;
          if (!s) return;
          s.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!s.started || s.pending) { s.started = true; s.pending = false; play(e.target); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-tiled-image-reveal]').forEach(function (el) {
        if (el._tir) return;
        el._tir = true;
        if (!setup(el)) return;
        if (reduced()) { el.classList.remove('tir-hide'); el._tirS.layer.style.display = 'none'; return; }
        io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.tiledReveal = function (el) { if (el._tirS) { el._tirS.turn = 0; play(el); } };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tiled-image-reveal data-effect="flip" style="aspect-ratio: 16 / 9">
  <img src="photo.jpg" alt="…">
</div>