Halftone Image
Redraw an image as a printed halftone: a screen of dots on a 45° grid, big where the picture is dark and small where it’s light. The dots grow in when it scrolls into view, then swell under the pointer like ink under a magnifier.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-halftone-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-spacing | Pixels between dots. Default 9. |
data-angle | Angle of the dot grid in degrees. Default 45. |
data-ink | Dot colour. Default #1d1b2f. |
data-paper | Background colour. Default a warm off-white, or near-black with data-color. |
data-color | Dots take the image’s own colours, bright areas getting big dots, on dark paper. |
data-swell | How much the dots grow under the pointer. Default 1; 0 turns it off. |
data-autoplay | A pretend pointer drifts across while nobody is pointing. Handy on phones. |
Touch works too: drag a finger across the picture. The original image stays in the page for screen readers.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Halftone Image: redraws an image as a printed halftone, a screen of dots
// on a 45-degree grid, big where the picture is dark and small where it is
// light. The dots grow in when it scrolls into view, and swell under the
// pointer (or a finger) like ink under a magnifier.
// Usage: <figure data-halftone-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 dots, but stays for screen readers.
// Optional: data-spacing (px between dots, default 9), data-angle (grid angle
// in degrees, default 45), data-ink (dot colour, default #1d1b2f),
// data-paper (background, default #f6f0e4), data-color (dots take the
// image's own colours, on a dark paper unless you set one),
// data-swell (how much dots grow under the pointer, default 1),
// data-autoplay (a pretend pointer drifts across while nobody is pointing).
var DEFAULTS = { spacing: 9, angle: 45, ink: '#1d1b2f', paper: '#f6f0e4', darkPaper: '#111018', swell: 1, radius: 110 };
var css =
'.hft{position:relative;overflow:hidden}' +
'.hft-src{opacity:0}' +
'.hft-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// 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); });
}
}
// Draw the image to fill w × h, cropping like object-fit: cover.
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 spacing = +d.spacing || DEFAULTS.spacing;
var angle = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
var color = d.color !== undefined && d.color !== 'false';
var ink = d.ink || DEFAULTS.ink;
var paper = d.paper || (color ? DEFAULTS.darkPaper : DEFAULTS.paper);
var swellBy = d.swell !== undefined ? +d.swell : DEFAULTS.swell;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'hft-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var sample = document.createElement('canvas');
var sctx = sample.getContext('2d', { willReadFrequently: true });
var w = 0, h = 0, dpr = 1, dots = [], grow = 0;
var pointer = { x: 0, y: 0, k: 0 }, real = null, lastReal = -1e9, t = 0;
// Sample the image once per size: every dot keeps its spot, tone and colour.
function build() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
if (!w || !h) return;
canvas.width = w * dpr;
canvas.height = h * dpr;
sample.width = w;
sample.height = h;
cover(sctx, img, iw, ih, w, h);
var px;
try { px = sctx.getImageData(0, 0, w, h).data; }
catch (err) { canvas.remove(); el.classList.remove('hft'); el.querySelector('.hft-src').classList.remove('hft-src'); return; }
dots = [];
var cos = Math.cos(angle), sin = Math.sin(angle);
var reach = Math.hypot(w, h) / 2 + spacing, cx = w / 2, cy = h / 2;
for (var v = -reach; v <= reach; v += spacing) {
for (var u = -reach; u <= reach; u += spacing) {
var x = cx + u * cos - v * sin, y = cy + u * sin + v * cos;
if (x < -spacing || y < -spacing || x > w + spacing || y > h + spacing) continue;
var sx = Math.min(w - 1, Math.max(0, x | 0)), sy = Math.min(h - 1, Math.max(0, y | 0));
var i = (sy * w + sx) * 4;
var lum = (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]) / 255;
// Ink: dark areas get big dots. Colour: bright areas do, on dark paper.
var tone = color ? 0.25 + 0.75 * lum : Math.pow(1 - lum, 1.2);
dots.push({ x: x, y: y, r: Math.sqrt(tone) * spacing * 0.62, c: color ? 'rgb(' + px[i] + ',' + px[i + 1] + ',' + px[i + 2] + ')' : null });
}
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = paper;
ctx.fillRect(0, 0, w, h);
var R = DEFAULTS.radius * Math.max(0.6, Math.min(1.4, w / 600)), R2 = R * R;
var max = spacing * 0.78;
ctx.fillStyle = ink;
if (!color) ctx.beginPath();
for (var i = 0; i < dots.length; i++) {
var dt = dots[i], r = dt.r * grow;
if (pointer.k > 0.01) {
var dx = dt.x - pointer.x, dy = dt.y - pointer.y, q = (dx * dx + dy * dy) / R2;
if (q < 4) r = Math.min(max, r * (1 + 0.9 * Math.exp(-q * 2.2) * pointer.k * swellBy) + 0.8 * Math.exp(-q * 3) * pointer.k * swellBy);
}
if (r < 0.3) continue;
if (color) {
ctx.fillStyle = dt.c;
ctx.beginPath();
ctx.arc(dt.x, dt.y, r, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.moveTo(dt.x + r, dt.y);
ctx.arc(dt.x, dt.y, r, 0, Math.PI * 2);
}
}
if (!color) ctx.fill();
}
build();
if (reduced()) {
grow = 1;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { build(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { build(); draw(); }).observe(el);
draw();
function move(e) {
var r = el.getBoundingClientRect();
real = { x: e.clientX - r.left, y: e.clientY - r.top };
lastReal = t;
}
el.addEventListener('pointermove', move);
el.addEventListener('pointerdown', move);
el.addEventListener('pointerleave', function () { real = null; });
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') real = null; });
var last = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
t += dt;
// Follow the real pointer, or wander when there's none and autoplay is on.
var target = null;
if (real) target = real;
else if (autoplay && t - lastReal > 2500) {
target = { x: w * (0.5 + 0.36 * Math.sin(t / 1700)), y: h * (0.5 + 0.3 * Math.sin(t / 1100 + 1)) };
}
var e = 1 - Math.pow(0.88, dt / 16);
if (target) {
if (pointer.k < 0.02) { pointer.x = target.x; pointer.y = target.y; }
pointer.x += (target.x - pointer.x) * e;
pointer.y += (target.y - pointer.y) * e;
}
pointer.k += ((target ? 1 : 0) - pointer.k) * e * 0.6;
draw();
}
});
// Dots grow in from nothing the first time it is seen; then it keeps drawing while visible.
var shown = false;
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
if (!shown) {
shown = true;
var g = { v: 0 };
anime.animate(g, { v: 1, duration: 1200, ease: 'out(3)', onUpdate: function () { grow = g.v; } });
}
last = 0;
timer.play();
} else timer.pause();
}, { threshold: 0.1 }).observe(el);
}
function init() {
document.querySelectorAll('[data-halftone-image]').forEach(function (el) {
if (el._hft) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._hft = true;
el.classList.add('hft');
src.classList.add('hft-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-halftone-image style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>