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
- Paste the snippet just before
</head>. - Make a tiny copy of your image, about 30 px wide (a few hundred bytes).
- Wrap the image in an element with
data-blur-up-image-loadand give that a size or anaspect-ratio. Put the tiny file in the image’ssrcand the full one indata-src.
Options
Add any of these attributes to the same element.
data-blur | How blurred the preview is. Default 20 px. |
data-duration | Time for the preview to sharpen. Default 900 ms. |
data-delay | Extra wait before revealing. Default 0 ms. |
data-spinner="false" | No little loading ring in the corner. |
data-loop | Blur 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.
<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><figure data-blur-up-image-load style="aspect-ratio: 16 / 9">
<img src="photo-tiny.jpg" data-src="photo.jpg" alt="…">
</figure>