← Filters & retro

Pixel Sort

The glitch-art classic. Within each column of an image, runs of pixels in a brightness range are sorted light to dark, so the picture smears into long melting streaks. The sort sweeps down when it scrolls into view, each column at its own pace.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-pixel-sort and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.

Options

Add any of these attributes to the same element.

data-directionvertical (default) streaks down; horizontal streaks across.
data-thresholdPixels darker than this stay put, from 0 to 1. Default 0.3. Lower it for longer streaks.
data-upperPixels brighter than this stay put too. Default 1.
data-reverseSort dark to light instead.
data-durationTime for the sweep. Default 2200 ms.
data-loopSweep in, hold, sweep back and go again.
data-hoverSort while hovered or touched, and unsort when the pointer leaves.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pixel Sort: the glitch-art effect. Within each column of an image, runs of
    // pixels in a brightness range are sorted from light to dark, so the picture
    // smears into long melting streaks. The sort sweeps down the image when it
    // scrolls into view, column by column at slightly different speeds.
    // Usage: <figure data-pixel-sort><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box and
    // is hidden behind the sorted copy, but stays for screen readers.
    // Optional: data-direction ("vertical", the default, or "horizontal"),
    // data-threshold (pixels darker than this, 0 to 1, stay put; default 0.3),
    // data-upper (pixels brighter than this stay put too; default 1),
    // data-reverse (sort dark to light instead), data-duration (ms for the
    // sweep, default 2200), data-loop (sweep in, hold, sweep back, repeat),
    // data-hover (sort while hovered or touched, unsort when left).
    var DEFAULTS = { threshold: 0.3, upper: 1, duration: 2200, hold: 1800, maxSize: 520 };

    var css =
      '.pxs{position:relative;overflow:hidden}' +
      '.pxs-src{opacity:0}' +
      '.pxs-canvas{position:absolute;inset:0;width:100%;height:100%;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; }

    // Turn the source (<img> or inline <svg>) into something drawImage can use.
    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 1000 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var horizontal = d.direction === 'horizontal';
      var lo = d.threshold !== undefined ? +d.threshold : DEFAULTS.threshold;
      var hi = d.upper !== undefined ? +d.upper : DEFAULTS.upper;
      var reverse = d.reverse !== undefined && d.reverse !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var hover = d.hover !== undefined && d.hover !== 'false';

      var canvas = document.createElement('canvas');
      canvas.className = 'pxs-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var work = document.createElement('canvas');
      var wctx = work.getContext('2d', { willReadFrequently: true });
      var pw = 0, ph = 0, orig = null, sorted = null, out = null, runs = [], delay = [];
      var state = { p: 0 };

      // Work at a modest resolution, find the runs and sort each one once.
      function build() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h) return false;
        var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h));
        pw = Math.max(1, Math.round(w * s));
        ph = Math.max(1, Math.round(h * s));
        work.width = pw;
        work.height = ph;
        wctx.imageSmoothingQuality = 'high';
        cover(wctx, img, iw, ih, pw, ph);
        try { orig = wctx.getImageData(0, 0, pw, ph); }
        catch (err) { canvas.remove(); el.querySelector('.pxs-src').classList.remove('pxs-src'); return false; }
        var p = orig.data, n = pw * ph, lum = new Float32Array(n);
        for (var i = 0; i < n; i++) lum[i] = (0.299 * p[i * 4] + 0.587 * p[i * 4 + 1] + 0.114 * p[i * 4 + 2]) / 255;
        sorted = new Uint8ClampedArray(p);
        out = wctx.createImageData(pw, ph);
        runs = [];
        delay = [];
        // Lines are columns (vertical) or rows (horizontal): pixel index = a * stepA + b * stepB.
        var lines = horizontal ? ph : pw, len = horizontal ? pw : ph;
        var stepLine = horizontal ? pw : 1, stepAlong = horizontal ? 1 : pw;
        for (var a = 0; a < lines; a++) {
          delay.push(Math.random() * 0.35 + 0.08 * Math.sin(a * 0.07));
          var b = 0;
          while (b < len) {
            var idx = a * stepLine + b * stepAlong;
            if (lum[idx] < lo || lum[idx] > hi) { b++; continue; }
            var start = b;
            while (b < len) {
              var j = a * stepLine + b * stepAlong;
              if (lum[j] < lo || lum[j] > hi) break;
              b++;
            }
            if (b - start < 3) continue;
            var ids = [];
            for (var q = start; q < b; q++) ids.push(a * stepLine + q * stepAlong);
            var order = ids.slice().sort(function (x, y) { return reverse ? lum[x] - lum[y] : lum[y] - lum[x]; });
            for (q = 0; q < ids.length; q++) {
              var to = ids[q] * 4, from = order[q] * 4;
              sorted[to] = p[from]; sorted[to + 1] = p[from + 1]; sorted[to + 2] = p[from + 2];
            }
            runs.push({ line: a, ids: ids });
          }
        }
        return true;
      }

      // Each run is sorted from its start up to how far the sweep has reached.
      function draw() {
        if (!orig) return;
        var o = out.data;
        o.set(orig.data);
        var prog = state.p;
        if (prog > 0) {
          for (var r = 0; r < runs.length; r++) {
            var run = runs[r], k = (prog * 1.45 - delay[run.line]) / 1.0;
            if (k <= 0) continue;
            var m = Math.min(run.ids.length, Math.round(k * run.ids.length));
            for (var q = 0; q < m; q++) {
              var i = run.ids[q] * 4;
              o[i] = sorted[i]; o[i + 1] = sorted[i + 1]; o[i + 2] = sorted[i + 2];
            }
          }
        }
        wctx.putImageData(out, 0, 0);
        var dpr = Math.min(2, window.devicePixelRatio || 1);
        var w = el.clientWidth, h = el.clientHeight;
        if (canvas.width !== Math.round(w * dpr)) canvas.width = Math.round(w * dpr);
        if (canvas.height !== Math.round(h * dpr)) canvas.height = Math.round(h * dpr);
        ctx.imageSmoothingEnabled = true;
        ctx.drawImage(work, 0, 0, canvas.width, canvas.height);
      }

      if (!build()) return;
      if (window.ResizeObserver) {
        var lastW = el.clientWidth, lastH = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lastW && el.clientHeight === lastH) return;
          lastW = el.clientWidth; lastH = el.clientHeight;
          if (build()) draw();
        }).observe(el);
      }
      if (reduced()) { state.p = 1; draw(); return; }
      draw();

      var anim = null;
      function to(v, dur, done) {
        if (anim) anim.pause();
        anim = anime.animate(state, { p: v, duration: dur, ease: 'inOut(2)', onUpdate: draw, onComplete: done });
      }

      var hovering = false, timer = null, visible = false, waiting = false;
      function cycle() {
        if (hovering) return;
        // Off screen: wait until it is back before sweeping again.
        if (!visible) { waiting = true; return; }
        to(1, duration, function () {
          if (!loop) return;
          timer = setTimeout(function () {
            to(0, duration * 0.7, function () { timer = setTimeout(cycle, 900); });
          }, DEFAULTS.hold);
        });
      }

      if (hover) {
        var enter = function () { hovering = true; clearTimeout(timer); to(1, duration * 0.6); };
        var leave = function () { hovering = false; to(0, duration * 0.5, function () { if (loop) timer = setTimeout(cycle, 1500); }); };
        el.addEventListener('pointerenter', enter);
        el.addEventListener('pointerleave', leave);
        el.addEventListener('pointercancel', leave);
      }

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) return;
        if (waiting) { waiting = false; cycle(); }
        if (started) return;
        started = true;
        if (!hover || loop) cycle();
      }, { threshold: 0.3 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-pixel-sort]').forEach(function (el) {
        if (el._pxs) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._pxs = true;
        el.classList.add('pxs');
        src.classList.add('pxs-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-pixel-sort data-loop style="height:400px">
  <img src="photo.jpg" alt="Northern lights">
</figure>