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
- Paste the snippet just before
</head>. - Add
data-jigsaw-assembleto a box holding your image, and give it a width.
Options
Add any of these attributes to the same element.
data-cols | Pieces across. Default 4. |
data-rows | Pieces down. Default 3. |
data-stagger | Gap between pieces. Default 140 ms. |
data-duration | Time each piece flies for. Default 800 ms. |
data-order | random, rows or spiral (edges first, then inwards). Default random. |
data-loop | Break it up and build it again. |
data-hold | How 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.
<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><div data-jigsaw-assemble data-cols="5" data-rows="3" style="width: 480px">
<img src="lake.jpg" alt="A mountain lake">
</div>