← Cards & images

Image Pixel Reveal

An image arrives as a handful of big, blocky pixels and sharpens step by step until it’s crisp, like a slow download from the early web. It plays when the image scrolls into view.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-pixel-reveal to a container holding an <img> or an inline <svg>, and give it a size or an aspect-ratio.

Options

Add any of these attributes to the same element.

data-sizeThe biggest pixel, in px. Default 48.
data-durationTime to sharpen. Default 1800 ms.
data-loopCrumble back into pixels and sharpen again every few seconds.

The pixels are drawn on a canvas over the real image, which stays in the page for screen readers and right-clicks, and takes over once it’s sharp. If the picture can’t be drawn, the image is simply shown. From code: animacious.pixelReveal(element) plays it again.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Pixel Reveal: an image arrives as a handful of big, blocky pixels
    // and sharpens step by step until it's crisp, like a slow download from
    // the early web. Plays when scrolled into view.
    // Usage:
    //   <div data-image-pixel-reveal>
    //     <img src="photo.jpg" alt="…">
    //   </div>
    // Works with an <img> or an inline <svg>. Optional: data-size (biggest
    // pixel in px, default 48), data-duration (ms, default 1800), data-loop
    // (pixelate and sharpen again every few seconds). From code:
    // animacious.pixelReveal(element) plays it again.
    var DEFAULTS = { size: 48, duration: 1800, hold: 2400 };

    var css =
      '[data-image-pixel-reveal]{position:relative;overflow:hidden}' +
      '[data-image-pixel-reveal]>img,[data-image-pixel-reveal]>svg{display:block;width:100%;height:100%;object-fit:cover}' +
      '.ipr-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;image-rendering:pixelated;image-rendering:crisp-edges}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // A full-size picture of the media to sample from, drawn the way it shows
    // on the page (object-fit: cover for images).
    function snapshot(media, w, h) {
      return new Promise(function (resolve, reject) {
        var img = new Image();
        var draw = function () {
          var c = document.createElement('canvas');
          c.width = w;
          c.height = h;
          var ctx = c.getContext('2d');
          if (media.tagName.toLowerCase() === 'svg') ctx.drawImage(img, 0, 0, w, h);
          else {
            var iw = img.naturalWidth, ih = img.naturalHeight, k = Math.max(w / iw, h / ih);
            ctx.drawImage(img, (w - iw * k) / 2, (h - ih * k) / 2, iw * k, ih * k);
          }
          resolve(c);
        };
        img.onload = function () { draw(); if (img.src.slice(0, 5) === 'blob:') URL.revokeObjectURL(img.src); };
        img.onerror = reject;
        if (media.tagName.toLowerCase() === 'svg') {
          var copy = media.cloneNode(true);
          copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
          copy.setAttribute('width', w);
          copy.setAttribute('height', h);
          img.src = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml' }));
        } else {
          img.crossOrigin = media.crossOrigin;
          img.src = media.currentSrc || media.src;
        }
      });
    }

    function setup(el) {
      var media = el.querySelector('img,svg');
      if (!media) return;
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
      var d = el.dataset;
      var max = Math.max(4, +d.size || DEFAULTS.size);
      var duration = +d.duration || DEFAULTS.duration;
      var canvas = document.createElement('canvas');
      canvas.className = 'ipr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      var ctx = canvas.getContext('2d');
      var small = document.createElement('canvas'), sctx = small.getContext('2d');
      var src = null, srcSize = '', block = 0, visible = false, played = false, busy = false, waiting = false;
      var state = { p: 0 };

      // Draw the picture with pixels `b` px wide: shrink it, then blow it back
      // up without smoothing.
      function render(b) {
        b = Math.max(1, Math.round(b));
        if (b === block || !src) return;
        block = b;
        var w = canvas.width, h = canvas.height;
        var sw = Math.ceil(w / b), sh = Math.ceil(h / b);
        small.width = sw;
        small.height = sh;
        sctx.imageSmoothingQuality = 'high';
        sctx.drawImage(src, 0, 0, sw, sh);
        ctx.imageSmoothingEnabled = false;
        ctx.clearRect(0, 0, w, h);
        ctx.drawImage(small, 0, 0, sw, sh, 0, 0, sw * b, sh * b);
      }

      // Pixel size for progress p (0 = biggest, 1 = sharp), shrinking evenly
      // on a log scale so each step feels like the same jump.
      function sizeAt(p) { return Math.pow(max, 1 - p); }

      function prepare() {
        var w = Math.round(el.clientWidth), h = Math.round(el.clientHeight);
        if (!w || !h) return Promise.reject(new Error('no size'));
        if (src && srcSize === w + 'x' + h) return Promise.resolve();
        return snapshot(media, w, h).then(function (c) {
          src = c;
          srcSize = w + 'x' + h;
          canvas.width = w;
          canvas.height = h;
          block = 0;
        });
      }

      function reveal() {
        if (busy) return;
        busy = true;
        prepare().then(function () {
          state.p = 0;
          block = 0;
          render(sizeAt(0));
          if (!canvas.parentNode) el.appendChild(canvas);
          anime.utils.set(canvas, { opacity: 1 });
          var tl = anime.createTimeline({ onComplete: done });
          tl.add(state, { p: 1, duration: duration, ease: 'inOut(2)', onUpdate: function () { render(sizeAt(state.p)); } })
            // The last step hands over to the real, sharp image.
            .add(canvas, { opacity: 0, duration: 260, ease: 'out(2)' }, '-=260');
        }).catch(function () { busy = false; if (canvas.parentNode) canvas.remove(); });
      }

      function done() {
        busy = false;
        if (d.loop === undefined) { canvas.remove(); return; }
        setTimeout(again, DEFAULTS.hold);
      }

      // Looping: crumble back into big pixels, then sharpen again.
      function again() {
        if (!visible || el.closest('[hidden]')) { waiting = true; return; }
        busy = true;
        prepare().then(function () {
          state.p = 1;
          block = 0;
          render(1);
          var tl = anime.createTimeline({ onComplete: function () { busy = false; setTimeout(reveal, 350); } });
          tl.add(canvas, { opacity: 1, duration: 200 })
            .add(state, { p: 0, duration: duration * 0.45, ease: 'in(2)', onUpdate: function () { render(sizeAt(state.p)); } });
        }).catch(function () { busy = false; });
      }

      // Show the blocky version straight away, so the sharp image never
      // flashes up first. If anything fails, the image is simply left as it is.
      prepare().then(function () {
        if (busy || played) return;
        render(sizeAt(0));
        el.appendChild(canvas);
      }).catch(function () { /* no size yet: it pixelates when it plays */ });

      el._ipr = reveal;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) return;
        if (!played) { played = true; reveal(); }
        else if (waiting) { waiting = false; again(); }
      }, { threshold: 0.3 }).observe(el);
    }

    window.animacious = window.animacious || {};
    window.animacious.pixelReveal = function (el) { if (el && el._ipr) el._ipr(); };

    function init() {
      document.querySelectorAll('[data-image-pixel-reveal]').forEach(function (el) {
        if (el._iprOn) return;
        el._iprOn = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-image-pixel-reveal style="aspect-ratio: 16 / 9">
  <img src="photo.jpg" alt="…">
</div>