Image to Particles
A picture breaks up into thousands of tiny coloured particles that peel away from left to right and whirl outwards like a whirlpool. They hang there turning for a moment, then fly back and click into place to form the picture again.
How to use it
- Paste the snippet just before
</head>. - Wrap the image in an element with
data-image-to-particles. The particles can roam the whole wrapper, so give it some room around the picture. - Clicking or tapping sets it off. Add
data-loopto have it break up and re-form by itself while it’s in view.
Options
Add any of these attributes to the same element.
data-loop | Break up and re-form over and over while in view. |
data-gap | Particle size in px. Smaller is finer but busier. Default 4. |
data-spread | How far they swirl, as a share of the wrapper. Default 1. |
data-hold | How long the picture stays whole between loops. Default 1800 ms. |
The particles take their colours from the image’s own pixels, so it has to come from your own site (or be served with CORS); otherwise it just stays as it is. The real image stays in the page for screen readers. With reduced motion nothing moves. From code, animacious.imageToParticles(element) sets it off.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Image to Particles: an image breaks up into thousands of little coloured
// particles that swirl away in a whirlpool, hang there turning, then fly
// back and click into place to form the picture again. Click or tap the
// image to set it off.
// Usage: <div data-image-to-particles><img src="photo.jpg" alt="…"></div>
// The particles can roam the whole wrapper, so give it room around the image.
// Optional: data-loop (break up and re-form by itself while in view),
// data-gap (particle size in px, default 4; smaller is finer but busier),
// data-spread (how far they swirl, as a share of the wrapper, default 1),
// data-hold (ms the picture stays whole between loops, default 1800).
// Script use: animacious.imageToParticles(element) sets it off.
// The image must be on your own site (or served with CORS) so its pixels
// can be read; otherwise it simply stays as it is.
var DEFAULTS = { gap: 4, spread: 1, hold: 1800, out: 1700, away: 1100, back: 1900 };
var css =
'[data-image-to-particles]{position:relative;-webkit-tap-highlight-color:transparent}' +
'[data-image-to-particles] img{cursor:pointer}' +
'.itp-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1}' +
'.itp-hidden{visibility:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function inOut(k) { return k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; }
function build(el) {
var img = el.querySelector('img');
if (!img) return;
var d = el.dataset;
var gap = Math.max(2, +d.gap || DEFAULTS.gap);
var spread = d.spread !== undefined ? +d.spread : DEFAULTS.spread;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var loop = d.loop !== undefined && d.loop !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'itp-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, cx = 0, cy = 0;
var parts = [];
var P = 0, spinT = 0, busy = false, ready = false, inView = false, waiting = null;
// Read the image's pixels at its displayed size, one particle per cell.
function sample() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
var er = el.getBoundingClientRect(), ir = img.getBoundingClientRect();
var iw = Math.round(ir.width), ih = Math.round(ir.height);
if (!iw || !ih || !w || !h) return false;
var ox = ir.left - er.left - el.clientLeft, oy = ir.top - er.top - el.clientTop;
var off = document.createElement('canvas');
off.width = iw;
off.height = ih;
var g = off.getContext('2d');
var data;
try {
g.drawImage(img, 0, 0, iw, ih);
data = g.getImageData(0, 0, iw, ih).data;
} catch (e) {
return false; // a cross-origin image without CORS: leave it alone
}
var rad = parseFloat(getComputedStyle(img).borderTopLeftRadius) || 0;
cx = ox + iw / 2;
cy = oy + ih / 2;
var maxR = Math.sqrt(w * w + h * h) / 2;
parts = [];
for (var y = 0; y < ih; y += gap) {
for (var x = 0; x < iw; x += gap) {
var sx = Math.min(iw - 1, Math.round(x + gap / 2)), sy = Math.min(ih - 1, Math.round(y + gap / 2));
// Skip the cut-off rounded corners.
if (rad) {
var qx = Math.max(rad - sx, sx - (iw - rad), 0), qy = Math.max(rad - sy, sy - (ih - rad), 0);
if (qx * qx + qy * qy > rad * rad) continue;
}
var i = (sy * iw + sx) * 4;
if (data[i + 3] < 60) continue;
var hx = ox + x, hy = oy + y;
var dx = hx + gap / 2 - cx, dy = hy + gap / 2 - cy;
var r0 = Math.sqrt(dx * dx + dy * dy);
parts.push({
hx: hx, hy: hy, r0: r0, a0: Math.atan2(dy, dx),
c: 'rgb(' + data[i] + ',' + data[i + 1] + ',' + data[i + 2] + ')',
// Leave from left to right, with a little scatter.
delay: clamp((hx - ox) / iw * 0.8 + Math.random() * 0.2),
spin: (0.7 + Math.random() * 0.9) * Math.PI * (1 + 40 / (r0 + 40)),
out: (0.08 + Math.random() * 0.36) * maxR * spread,
wob: Math.random() * Math.PI * 2
});
}
}
return parts.length > 0;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (P <= 0) return;
var squash = Math.min(1, (h / w) * 1.3);
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
var q = inOut(clamp((P - p.delay * 0.4) / 0.6));
var x, y;
if (q <= 0) { x = Math.round(p.hx); y = Math.round(p.hy); }
else {
// Whirl round the middle of the image, flung outwards as they go,
// still turning slowly while they hang there.
var a = p.a0 + q * p.spin + spinT * 0.0006 * q;
var r = p.r0 + q * p.out;
var hx = p.hx + gap / 2 - cx, hy = p.hy + gap / 2 - cy;
var tx = Math.cos(a) * r, ty = Math.sin(a) * r * squash + Math.sin(spinT * 0.002 + p.wob) * 6 * q;
x = cx + hx + (tx - hx) * q - gap / 2;
y = cy + hy + (ty - hy) * q - gap / 2;
}
// A hair of overlap while in place, so no seams show between them.
var s = q <= 0 ? gap + 0.5 : gap * (1 - q * 0.3);
ctx.globalAlpha = 1 - q * 0.15;
ctx.fillStyle = p.c;
ctx.fillRect(x, y, s, s);
}
ctx.globalAlpha = 1;
}
function setP(v) {
P = v;
if (P > 0) img.classList.add('itp-hidden');
else img.classList.remove('itp-hidden');
draw();
}
function play() {
if (busy || !ready) return;
busy = true;
clearTimeout(waiting);
var state = { p: 0 };
var start = performance.now();
var spinner = anime.createTimer({
loop: true, duration: 1000,
onUpdate: function () { spinT = performance.now() - start; }
});
anime.createTimeline({
onComplete: function () {
spinner.pause();
spinT = 0;
setP(0);
busy = false;
if (loop && inView) waiting = setTimeout(play, hold);
}
})
.add(state, { p: 1, duration: DEFAULTS.out + 500, ease: 'linear', onUpdate: function () { setP(state.p); } })
.add(state, { p: 1, duration: DEFAULTS.away, onUpdate: function () { draw(); } })
.add(state, { p: 0, duration: DEFAULTS.back + 500, ease: 'linear', onUpdate: function () { setP(state.p); } });
}
function prepare() {
ready = sample();
if (ready && loop && inView && !busy) waiting = setTimeout(play, 600);
}
if (reduced()) return;
el.addEventListener('click', play);
el._itpPlay = play;
if (window.ResizeObserver) {
var t = null;
new ResizeObserver(function () {
clearTimeout(t);
t = setTimeout(function () { if (!busy) { ready = sample(); draw(); } }, 150);
}).observe(el);
}
new IntersectionObserver(function (entries) {
inView = entries[0].isIntersecting;
clearTimeout(waiting);
if (inView && loop && ready && !busy) waiting = setTimeout(play, 600);
}, { threshold: 0.3 }).observe(el);
if (img.complete && img.naturalWidth) prepare();
else img.addEventListener('load', prepare);
}
function init() {
document.querySelectorAll('[data-image-to-particles]').forEach(function (el) {
if (el._itp) return;
el._itp = true;
build(el);
});
}
window.animacious = window.animacious || {};
window.animacious.imageToParticles = function (el) { if (el && el._itpPlay) el._itpPlay(); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-image-to-particles data-loop>
<img src="/images/sunset.jpg" alt="Sunset over the lake">
</div>