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
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-dithered-imageand give the wrapper a size. The image fills it, cropped to cover. - 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-palette | mono (default), gameboy, amber, cga or pico. A comma list, like gameboy,mono, to cycle with data-loop. |
data-colors | Your own palette, dark to light, e.g. #111,#e11d48,#fff. |
data-pixel | Size of each pixel. Default 3 px. |
data-loop | Step through the palettes, sharpening in again each time. |
data-interval | Time on each palette when looping. Default 2800 ms. |
<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><figure data-dithered-image data-palette="gameboy" style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>