← Filters & retro

Dithered Image

Render a photo in retro ordered dithering: a tiny palette and chunky pixels arranged in a Bayer pattern, like an old handheld or an early Mac. It sharpens into place from big blocks as it scrolls into view, and can step through palettes on a loop.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-dithered-image 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-palettemono (default), gameboy, amber, cga or pico. A comma list, like gameboy,mono, to cycle with data-loop.
data-colorsYour own palette, dark to light, e.g. #111,#e11d48,#fff.
data-pixelSize of each pixel. Default 3 px.
data-loopStep through the palettes, sharpening in again each time.
data-intervalTime on each palette when looping. Default 2800 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dithered Image: renders a photo in retro ordered (Bayer) dithering, with a
    // tiny palette and chunky pixels, like an old handheld or an early Mac. It
    // sharpens into place from big blocks when it scrolls into view. With
    // data-loop it steps through its palettes, sharpening in again each time.
    // Usage: <figure data-dithered-image><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 dithered copy, but stays for screen readers.
    // Optional: data-palette ("mono", the default, "gameboy", "amber", "cga" or
    // "pico"; a comma list like "gameboy,mono" to use with data-loop),
    // data-colors (your own palette, e.g. "#000,#e11d48,#fff"; dark to light),
    // data-pixel (pixel size in px, default 3), data-loop (cycle the palettes),
    // data-interval (ms per palette when looping, default 2800).
    var DEFAULTS = { palette: 'mono', pixel: 3, interval: 2800 };
    // Tone palettes run dark to light and dither on brightness; colour palettes
    // pick the nearest colour.
    var PALETTES = {
      mono: { tone: true, c: ['#1b1a22', '#f1ece0'] },
      gameboy: { tone: true, c: ['#0f380f', '#306230', '#8bac0f', '#c4cfa1'] },
      amber: { tone: true, c: ['#170c00', '#6b3a00', '#d98a00', '#ffcf5a'] },
      cga: { tone: false, c: ['#000000', '#55ffff', '#ff55ff', '#ffffff'] },
      pico: { tone: false, c: ['#000000', '#1d2b53', '#7e2553', '#008751', '#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
        '#ff004d', '#ffa300', '#ffec27', '#00e436', '#29adff', '#83769c', '#ff77a8', '#ffccaa'] }
    };

    var css =
      '.dth{position:relative;overflow:hidden}' +
      '.dth-src{opacity:0}' +
      '.dth-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;image-rendering:pixelated}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // An 8 × 8 Bayer matrix, as thresholds between 0 and 1.
    var BAYER = (function () {
      var m = [[0]];
      for (var n = 1; n < 8; n *= 2) {
        var next = [];
        for (var y = 0; y < n * 2; y++) {
          next.push([]);
          for (var x = 0; x < n * 2; x++) {
            var q = [0, 2, 3, 1][(y < n ? 0 : 2) + (x < n ? 0 : 1)];
            next[y].push(4 * m[y % n][x % n] + q);
          }
        }
        m = next;
      }
      return m.map(function (row) { return row.map(function (v) { return (v + 0.5) / 64; }); });
    })();

    function rgb(hex) {
      hex = hex.trim().replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }

    // 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 palettes;
      if (d.colors) palettes = [{ tone: true, c: d.colors.split(',') }];
      else palettes = (d.palette || DEFAULTS.palette).split(',').map(function (n) { return PALETTES[n.trim()] || PALETTES.mono; });
      palettes = palettes.map(function (p) { return { tone: p.tone, c: p.c.map(rgb) }; });
      var pixel = Math.max(1, +d.pixel || DEFAULTS.pixel);
      var loop = d.loop !== undefined && d.loop !== 'false' && palettes.length > 1;
      var interval = +d.interval || DEFAULTS.interval;

      var canvas = document.createElement('canvas');
      canvas.className = 'dth-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var small = document.createElement('canvas');
      var sctx = small.getContext('2d', { willReadFrequently: true });
      var w = 0, h = 0, dpr = 1, which = 0, size = pixel;

      function render() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        var cols = Math.ceil(w / size), rows = Math.ceil(h / size);
        small.width = cols;
        small.height = rows;
        sctx.imageSmoothingEnabled = true;
        sctx.imageSmoothingQuality = 'high';
        cover(sctx, img, iw, ih, cols, rows);
        var data;
        try { data = sctx.getImageData(0, 0, cols, rows); }
        catch (err) { canvas.remove(); el.querySelector('.dth-src').classList.remove('dth-src'); return; }
        var p = data.data, pal = palettes[which], c = pal.c, n = c.length;
        var spread = 255 / Math.max(1.6, Math.cbrt(n)) * 0.75;
        for (var y = 0; y < rows; y++) {
          for (var x = 0; x < cols; x++) {
            var i = (y * cols + x) * 4, th = BAYER[y & 7][x & 7], best;
            if (pal.tone) {
              var lum = (0.299 * p[i] + 0.587 * p[i + 1] + 0.114 * p[i + 2]) / 255;
              var v = lum * (n - 1), base = Math.floor(v);
              best = c[Math.min(n - 1, base + (v - base > th ? 1 : 0))];
            } else {
              var o = (th - 0.5) * spread, r = p[i] + o, g = p[i + 1] + o, b = p[i + 2] + o, bd = 1e9;
              for (var k = 0; k < n; k++) {
                var dr = r - c[k][0], dg = g - c[k][1], db = b - c[k][2];
                var dd = dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11;
                if (dd < bd) { bd = dd; best = c[k]; }
              }
            }
            p[i] = best[0]; p[i + 1] = best[1]; p[i + 2] = best[2]; p[i + 3] = 255;
          }
        }
        sctx.putImageData(data, 0, 0);
        ctx.imageSmoothingEnabled = false;
        ctx.drawImage(small, 0, 0, cols * size * dpr, rows * size * dpr);
      }

      // Sharpen in: big blocks stepping down to the final pixel size.
      var stepTimer = null;
      function sharpen() {
        var sizes = [28, 18, 12, 8, 5].filter(function (s) { return s > pixel; }).concat(pixel);
        var i = 0;
        clearTimeout(stepTimer);
        (function step() {
          size = sizes[i++];
          render();
          if (i < sizes.length) stepTimer = setTimeout(step, 90);
        })();
      }

      if (window.ResizeObserver) new ResizeObserver(function () { render(); }).observe(el);
      if (reduced()) { render(); return; }
      size = 28;
      render();

      var loopTimer = null, seen = false;
      function next() {
        which = (which + 1) % palettes.length;
        sharpen();
        loopTimer = setTimeout(next, interval);
      }
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!seen) { seen = true; sharpen(); }
          if (loop && !loopTimer) loopTimer = setTimeout(next, interval);
        } else if (loopTimer) { clearTimeout(loopTimer); loopTimer = null; }
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-dithered-image]').forEach(function (el) {
        if (el._dth) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._dth = true;
        el.classList.add('dth');
        src.classList.add('dth-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-dithered-image data-palette="gameboy" style="height:400px">
  <img src="photo.jpg" alt="A mountain lake">
</figure>