← Particles

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.

A sunset over mountains and a lake

How to use it

  1. Paste the snippet just before </head>.
  2. 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.
  3. Clicking or tapping sets it off. Add data-loop to 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-loopBreak up and re-form over and over while in view.
data-gapParticle size in px. Smaller is finer but busier. Default 4.
data-spreadHow far they swirl, as a share of the wrapper. Default 1.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-image-to-particles data-loop>
  <img src="/images/sunset.jpg" alt="Sunset over the lake">
</div>