← Cards & images

Jigsaw Assemble

A picture is cut into real jigsaw pieces, with tabs and blanks, which tumble onto the board scattered and turned. Then they fly in one at a time and drop into place until the picture is whole and the seams fade away.

Tap the picture to break it up again

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-jigsaw-assemble to a box holding your image, and give it a width.

Options

Add any of these attributes to the same element.

data-colsPieces across. Default 4.
data-rowsPieces down. Default 3.
data-staggerGap between pieces. Default 140 ms.
data-durationTime each piece flies for. Default 800 ms.
data-orderrandom, rows or spiral (edges first, then inwards). Default random.
data-loopBreak it up and build it again.
data-holdHow long the finished picture stays. Default 2400 ms.

The tabs are different every time the page loads. It builds when it scrolls into view, and a click or tap breaks it up and builds it again. From code: animacious.jigsaw(element). With reduced motion the picture is just shown.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Jigsaw Assemble: an image is cut into jigsaw pieces, with real tabs and
    // blanks, which lie scattered and turned over the board, then fly in one
    // by one and click into place until the picture is whole and the seams fade.
    // Usage: <div data-jigsaw-assemble style="width: 480px">
    //          <img src="photo.jpg" alt="A mountain lake">
    //        </div>
    // Optional: data-cols (default 4), data-rows (default 3), data-stagger (ms
    // between pieces, default 140), data-duration (ms each piece flies,
    // default 800), data-order ("random", default, "rows" or "spiral"),
    // data-loop, data-hold (ms the finished picture shows before it breaks up
    // again, default 2400). A click or tap breaks it up and builds it again.
    // Script use: animacious.jigsaw(el).
    var DEFAULTS = { cols: 4, rows: 3, stagger: 140, duration: 800, hold: 2400 };

    var css =
      '.jga{position:relative;-webkit-tap-highlight-color:transparent}' +
      '.jga-img{transition:opacity .4s}' +
      '.jga-img>*{display:block;width:100%;height:auto}' +
      '.jga-p{position:absolute;pointer-events:none;filter:drop-shadow(0 3px 4px rgba(20,10,40,.35));will-change:transform}' +
      '.jga-p.jga-in{filter:none}' +
      '.jga-c{position:absolute;inset:0}' +
      '.jga-c>.jga-copy{position:absolute}' +
      '.jga-c>.jga-copy>*{display:block;width:100%;height:100%}' +
      '.jga-p>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;transition:opacity .5s}' +
      '.jga-board{position:absolute;inset:0;border-radius:inherit;background:rgba(106,13,173,.06);box-shadow:inset 0 0 0 1.5px rgba(106,13,173,.18);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; }

    // One edge of a piece, from (ax, ay) to (bx, by), going clockwise. k is +1
    // for a tab sticking out, -1 for a blank, 0 for a flat outer edge.
    var KNOB = [
      [0.40, 0, 0.44, 0, 0.45, 0.07, 0.41, 0.12],
      [0, 0, 0.33, 0.20, 0.36, 0.30, 0.50, 0.30],
      [0, 0, 0.64, 0.30, 0.67, 0.20, 0.59, 0.12],
      [0, 0, 0.55, 0.07, 0.56, 0, 0.60, 0]
    ];
    function edge(ax, ay, bx, by, k, size) {
      if (!k) return ' L' + bx.toFixed(2) + ' ' + by.toFixed(2);
      var dx = bx - ax, dy = by - ay, len = Math.sqrt(dx * dx + dy * dy);
      var ux = dx / len, uy = dy / len, nx = uy, ny = -ux;
      // Points are [along the edge as a fraction, out from it in piece-sizes].
      function pt(u, v) {
        var a = (u - 0.5) * size + len / 2;
        return (ax + ux * a + nx * v * size * k).toFixed(2) + ' ' + (ay + uy * a + ny * v * size * k).toFixed(2);
      }
      var p = ' L' + pt(KNOB[0][0], KNOB[0][1]);
      KNOB.forEach(function (c) { p += ' C' + pt(c[2], c[3]) + ' ' + pt(c[4], c[5]) + ' ' + pt(c[6], c[7]); });
      return p + ' L' + bx.toFixed(2) + ' ' + by.toFixed(2);
    }

    function setup(el) {
      var d = el.dataset;
      var pic = el.firstElementChild;
      if (!pic) return;
      el.classList.add('jga');
      var wrap = document.createElement('div');
      wrap.className = 'jga-img';
      el.insertBefore(wrap, pic);
      wrap.appendChild(pic);
      var cols = Math.max(1, parseInt(d.cols, 10) || DEFAULTS.cols);
      var rows = Math.max(1, parseInt(d.rows, 10) || DEFAULTS.rows);
      // Which way each inside edge's tab points.
      var hz = [], vt = [];
      for (var r = 0; r < rows; r++) {
        hz.push([]); vt.push([]);
        for (var c = 0; c < cols; c++) {
          hz[r].push(Math.random() < 0.5 ? 1 : -1);
          vt[r].push(Math.random() < 0.5 ? 1 : -1);
        }
      }
      var board = document.createElement('div');
      board.className = 'jga-board';
      board.setAttribute('aria-hidden', 'true');
      el.insertBefore(board, wrap);
      var pieces = [];
      for (r = 0; r < rows; r++) {
        for (c = 0; c < cols; c++) {
          var p = document.createElement('div');
          p.className = 'jga-p';
          p.setAttribute('aria-hidden', 'true');
          var clip = document.createElement('div');
          clip.className = 'jga-c';
          var copy = document.createElement('div');
          copy.className = 'jga-copy';
          copy.appendChild(pic.cloneNode(true));
          clip.appendChild(copy);
          var line = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
          var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
          path.setAttribute('fill', 'none');
          path.setAttribute('stroke', 'rgba(255,255,255,.55)');
          path.setAttribute('stroke-width', '1');
          line.appendChild(path);
          p.appendChild(clip);
          p.appendChild(line);
          el.appendChild(p);
          pieces.push({ el: p, clip: clip, path: path, line: line, r: r, c: c });
        }
      }
      var s = el._jgaS = { pieces: pieces, wrap: wrap, board: board, cols: cols, rows: rows, hz: hz, vt: vt, tl: null, timer: 0, visible: false };

      // Each piece is a box just big enough for its cell and its tabs, holding
      // a copy of the whole picture shifted so the right part shows through.
      function shape() {
        var W = el.clientWidth, H = el.clientHeight;
        var cw = W / cols, ch = H / rows, size = Math.min(cw, ch), m = Math.ceil(size * 0.32);
        s.cw = cw; s.ch = ch;
        pieces.forEach(function (pc) {
          var r = pc.r, c = pc.c, ox = c * cw - m, oy = r * ch - m;
          var pw = cw + 2 * m, ph = ch + 2 * m;
          var st = pc.el.style;
          st.left = ox.toFixed(2) + 'px'; st.top = oy.toFixed(2) + 'px';
          st.width = pw.toFixed(2) + 'px'; st.height = ph.toFixed(2) + 'px';
          var copy = pc.clip.firstChild.style;
          copy.left = -ox + 'px'; copy.top = -oy + 'px'; copy.width = W + 'px'; copy.height = H + 'px';
          var x0 = m, y0 = m, x1 = m + cw, y1 = m + ch;
          var dp = 'M' + x0.toFixed(2) + ' ' + y0.toFixed(2);
          dp += edge(x0, y0, x1, y0, r ? -hz[r - 1][c] : 0, size);       // top
          dp += edge(x1, y0, x1, y1, c < cols - 1 ? vt[r][c] : 0, size);  // right
          dp += edge(x1, y1, x0, y1, r < rows - 1 ? hz[r][c] : 0, size);  // bottom
          dp += edge(x0, y1, x0, y0, c ? -vt[r][c - 1] : 0, size);       // left
          dp += ' Z';
          pc.clip.style.clipPath = 'path("' + dp + '")';
          pc.path.setAttribute('d', dp);
          pc.line.setAttribute('viewBox', '0 0 ' + pw.toFixed(2) + ' ' + ph.toFixed(2));
          pc.cx = c * cw + cw / 2; pc.cy = r * ch + ch / 2;
        });
      }
      shape();
      var lastW = el.clientWidth, lastH = el.clientHeight;
      new ResizeObserver(function () {
        if (el.clientWidth === lastW && el.clientHeight === lastH) return;
        lastW = el.clientWidth; lastH = el.clientHeight;
        shape();
        if (!s.busy) pieces.forEach(function (pc) { if (pc.home) anime.utils.set(pc.el, { x: 0, y: 0, rotate: 0 }); });
      }).observe(el);

      if (reduced()) { done(el, true); return; }
      scatter(el);
      anime.utils.set(pieces.map(function (pc) { return pc.el; }), { opacity: 0 });
      el.addEventListener('click', function () { play(el); });
      var played = false;
      new IntersectionObserver(function (en) {
        s.visible = en[0].isIntersecting;
        if (s.visible && !played) { played = true; play(el, true); }
        if (s.visible && s.waiting) { s.waiting = false; play(el); }
      }, { threshold: 0.3 }).observe(el);
    }

    // Somewhere on the board for each piece, turned at random.
    function spots(el) {
      var s = el._jgaS, W = el.clientWidth, H = el.clientHeight;
      return s.pieces.map(function (pc) {
        var tx = s.cw * 0.5 + Math.random() * (W - s.cw), ty = s.ch * 0.5 + Math.random() * (H - s.ch);
        return { x: tx - pc.cx, y: ty - pc.cy, rotate: (Math.random() * 2 - 1) * 50, scale: 0.92 };
      });
    }

    function scatter(el) {
      var s = el._jgaS, sp = spots(el);
      s.wrap.style.opacity = 0;
      s.pieces.forEach(function (pc, i) {
        pc.home = false;
        pc.el.classList.remove('jga-in');
        pc.line.style.opacity = 1;
        pc.el.style.zIndex = i + 1;
        anime.utils.set(pc.el, sp[i]);
      });
    }

    function order(el) {
      var s = el._jgaS, list = s.pieces.slice(), how = el.dataset.order || 'random';
      if (how === 'rows') return list;
      if (how === 'spiral') {
        var cx = (s.cols - 1) / 2, cy = (s.rows - 1) / 2;
        return list.sort(function (a, b) {
          var da = Math.max(Math.abs(a.c - cx), Math.abs(a.r - cy)), db = Math.max(Math.abs(b.c - cx), Math.abs(b.r - cy));
          return db - da || Math.atan2(a.r - cy, a.c - cx) - Math.atan2(b.r - cy, b.c - cx);
        });
      }
      for (var i = list.length - 1; i > 0; i--) {
        var j = Math.floor(Math.random() * (i + 1)), t = list[i];
        list[i] = list[j]; list[j] = t;
      }
      return list;
    }

    function play(el, fresh) {
      var s = el._jgaS, d = el.dataset;
      if (!s || reduced()) return;
      if (s.tl) s.tl.pause();
      clearTimeout(s.timer);
      s.busy = true;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var dur = +d.duration || DEFAULTS.duration;
      var tl = s.tl = anime.createTimeline();
      var t = 0;
      var sp = spots(el);
      if (fresh) {
        // Pieces tumble onto the board.
        s.pieces.forEach(function (pc, i) {
          tl.add(pc.el, { opacity: [0, 1], scale: [0.4, sp[i].scale], duration: 380, ease: 'outBack(2)' }, i * 25);
        });
        t = s.pieces.length * 25 + 500;
      } else {
        // Break it up: the pieces lift and slide apart across the board.
        tl.call(function () {
          s.wrap.style.opacity = 0;
          s.pieces.forEach(function (pc, i) {
            pc.home = false;
            pc.el.classList.remove('jga-in');
            pc.line.style.opacity = 1;
            pc.el.style.zIndex = i + 1;
          });
        }, 0);
        s.pieces.forEach(function (pc, i) {
          tl.add(pc.el, Object.assign({ duration: 650, ease: 'inOut(3)' }, sp[i]), 50 + (i % 5) * 30);
        });
        t = 900;
      }
      order(el).forEach(function (pc, k) {
        var at = t + k * stagger;
        tl.call(function () { pc.el.style.zIndex = 100 + k; }, at);
        tl.add(pc.el, {
          x: 0, y: 0, rotate: 0,
          scale: [{ to: 1.08, duration: dur * 0.6, ease: 'out(2)' }, { to: 1, duration: dur * 0.4, ease: 'in(2)' }],
          duration: dur, ease: 'inOut(3)'
        }, at);
        tl.call(function () { pc.home = true; pc.el.classList.add('jga-in'); }, at + dur);
      });
      var end = t + (s.pieces.length - 1) * stagger + dur;
      tl.call(function () { done(el); }, end + 120);
      tl.call(function () {
        s.busy = false;
        if (d.loop !== undefined) s.timer = setTimeout(function () {
          if (s.visible) play(el); else s.waiting = true;
        }, d.hold !== undefined ? +d.hold : DEFAULTS.hold);
      }, end + 600);
    }

    // The whole picture comes back and the seams fade away.
    function done(el, instant) {
      var s = el._jgaS;
      s.pieces.forEach(function (pc) {
        pc.home = true;
        pc.el.classList.add('jga-in');
        pc.line.style.opacity = 0;
        if (instant) anime.utils.set(pc.el, { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 });
      });
      s.wrap.style.opacity = 1;
    }

    function init() {
      document.querySelectorAll('[data-jigsaw-assemble]').forEach(function (el) {
        if (el._jga) return;
        el._jga = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.jigsaw = function (el) { play(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-jigsaw-assemble data-cols="5" data-rows="3" style="width: 480px">
  <img src="lake.jpg" alt="A mountain lake">
</div>