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
- Paste the snippet just before
</head>. - Add
data-image-pixel-revealto a container holding an<img>or an inline<svg>, and give it a size or anaspect-ratio.
Options
Add any of these attributes to the same element.
data-size | The biggest pixel, in px. Default 48. |
data-duration | Time to sharpen. Default 1800 ms. |
data-loop | Crumble 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.
<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><div data-image-pixel-reveal style="aspect-ratio: 16 / 9">
<img src="photo.jpg" alt="…">
</div>