← Cards & images

Blur-up Image Load

A tiny, blurry preview fills the space at once, so the page never shows an empty box. The full image loads in the background as it nears the screen, then the preview sharpens into it as it fades in.

How to use it

  1. Paste the snippet just before </head>.
  2. Make a tiny copy of your image, about 30 px wide (a few hundred bytes).
  3. Wrap the image in an element with data-blur-up-image-load and give that a size or an aspect-ratio. Put the tiny file in the image’s src and the full one in data-src.

Options

Add any of these attributes to the same element.

data-blurHow blurred the preview is. Default 20 px.
data-durationTime for the preview to sharpen. Default 900 ms.
data-delayExtra wait before revealing. Default 0 ms.
data-spinner="false"No little loading ring in the corner.
data-loopBlur and sharpen again every few seconds (for demos).

The full image starts loading 200 px before it scrolls into view and is decoded before it’s shown, so it never appears half-drawn. A data-srcset is used too. Without a data-src (an inline SVG, or an image that’s already loaded, as in this demo) the script makes the tiny preview itself.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Blur-up Image Load: a tiny, blurred preview fills the space straight
    // away, the full image loads in the background when it nears the screen,
    // and then the preview sharpens into it as the full image fades in.
    // Usage:
    //   <figure data-blur-up-image-load style="aspect-ratio: 16 / 9">
    //     <img src="photo-tiny.jpg" data-src="photo.jpg" alt="…">
    //   </figure>
    // The <img> starts with a tiny version (20–40 px wide is plenty) as its src
    // and the full one in data-src. Give the container a size.
    // With no data-src (e.g. an inline <svg> or an image already loaded) the
    // script makes the tiny preview itself and plays the reveal; pair it with
    // data-delay to show the effect.
    // Optional: data-blur (px, default 20), data-duration (ms, default 900),
    // data-delay (extra wait before revealing, ms, default 0), data-spinner=
    // "false" (no loading ring), data-loop (blur and reveal again, for demos).
    var DEFAULTS = { blur: 20, duration: 900, delay: 0, hold: 2600 };

    var css =
      '[data-blur-up-image-load]{position:relative;overflow:hidden}' +
      '[data-blur-up-image-load]>img,[data-blur-up-image-load]>svg,.bul-ph{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:cover}' +
      '.bul-ph{transform-origin:50% 50%}' +
      '.bul-spin{position:absolute;right:12px;bottom:12px;z-index:3;width:22px;height:22px;border:2.5px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:bul-spin .8s linear infinite;pointer-events:none}' +
      '@keyframes bul-spin{to{transform:rotate(360deg)}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // Draw a picture into a canvas a couple of dozen pixels wide.
    function tiny(el, pic, cb) {
      var w = 24, h = Math.max(1, Math.round(w * (el.clientHeight || 9) / (el.clientWidth || 16)));
      var c = document.createElement('canvas');
      c.width = w;
      c.height = h;
      var draw = function (img) {
        try {
          var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
          var s = Math.max(w / iw, h / ih);   // cover
          c.getContext('2d').drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
          cb(c);
        } catch (e) { cb(null); }
      };
      if (pic.tagName.toLowerCase() === 'svg') {
        var copy = pic.cloneNode(true);
        copy.removeAttribute('style');   // it's hidden on the page until revealed
        copy.setAttribute('width', w);
        copy.setAttribute('height', h);
        var url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(copy)], { type: 'image/svg+xml' }));
        var im = new Image();
        // Sized to the canvas already (its own preserveAspectRatio crops it), so draw it as is.
        im.onload = function () {
          try { c.getContext('2d').drawImage(im, 0, 0, w, h); cb(c); } catch (e) { cb(null); }
          URL.revokeObjectURL(url);
        };
        im.onerror = function () { cb(null); URL.revokeObjectURL(url); };
        im.src = url;
      } else if (pic.complete && pic.naturalWidth) draw(pic);
      else pic.addEventListener('load', function () { draw(pic); }, { once: true });
    }

    function setup(el) {
      var d = el.dataset;
      var blur = d.blur !== undefined ? +d.blur : DEFAULTS.blur;
      var duration = +d.duration || DEFAULTS.duration;
      var delay = +d.delay || DEFAULTS.delay;
      var pic = el.querySelector(':scope > img, :scope > svg, :scope > picture > img');
      if (!pic) return;
      var full = pic.dataset && pic.dataset.src ? pic.dataset.src : null;
      var ph = null, sharp = null, spin = null, state = { b: blur, o: 0 };

      function paint() {
        if (ph) ph.style.filter = 'blur(' + state.b.toFixed(2) + 'px)';
        if (ph) ph.style.transform = 'scale(' + (1 + 0.1 * state.b / Math.max(1, blur)).toFixed(4) + ')';
        sharp.style.opacity = state.o.toFixed(3);
      }

      if (full) {
        // The <img> holds the tiny file: it becomes the blurred preview.
        ph = pic;
        ph.classList.add('bul-ph');
        sharp = document.createElement('img');
        sharp.alt = '';
        sharp.setAttribute('aria-hidden', 'true');
        sharp.className = 'bul-full';
        sharp.style.position = 'absolute';
        sharp.style.inset = '0';
        sharp.style.width = sharp.style.height = '100%';
        sharp.style.objectFit = 'cover';
        el.appendChild(sharp);
      } else {
        // Already here: hide it and blur a home-made tiny copy in its place.
        sharp = pic;
      }
      paint();

      if (d.spinner !== 'false') {
        spin = document.createElement('span');
        spin.className = 'bul-spin';
        spin.setAttribute('aria-hidden', 'true');
        el.appendChild(spin);
      }

      function reveal() {
        if (spin) anime.animate(spin, { opacity: 0, scale: 0.6, duration: 250, ease: 'out(2)' });
        if (reduced()) { state.b = 0; state.o = 1; return paint(); }
        anime.animate(state, { b: 0, o: 1, duration: duration, ease: 'out(3)', onUpdate: paint, onComplete: rest });
      }

      // Demo loop: blur back to the preview, then "load" again.
      function rest() {
        if (d.loop === undefined) return;
        setTimeout(function () {
          if (spin) anime.utils.set(spin, { opacity: 1, scale: 1 });
          anime.animate(state, { b: blur, o: 0, duration: 500, ease: 'in(2)', onUpdate: paint, onComplete: function () { setTimeout(reveal, delay || 1200); } });
        }, DEFAULTS.hold);
      }

      function load() {
        if (full) {
          var img = new Image();
          img.onload = function () {
            if (pic.dataset.srcset) { sharp.sizes = pic.sizes || '100vw'; sharp.srcset = pic.dataset.srcset; }
            sharp.src = full;
            var ready = sharp.decode ? sharp.decode().catch(function () {}) : Promise.resolve();
            ready.then(function () { setTimeout(reveal, delay); });
          };
          img.onerror = function () { if (spin) spin.remove(); };
          img.src = full;
        } else setTimeout(reveal, delay);
      }

      if (!full) {
        tiny(el, pic, function (canvas) {
          if (canvas) {
            ph = canvas;
            ph.className = 'bul-ph';
            ph.setAttribute('aria-hidden', 'true');
            el.insertBefore(ph, el.firstChild);
            paint();
          }
        });
      }

      new IntersectionObserver(function (entries, io) {
        if (!entries[0].isIntersecting) return;
        io.disconnect();
        load();
      }, { rootMargin: '200px 0px' }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-blur-up-image-load]').forEach(function (el) {
        if (el._bul) return;
        el._bul = true;
        setup(el);
      });
    }

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