Halftone Wave
A field of halftone dots, like a printed picture seen up close, swells and shrinks in waves that roll across the section while rings ripple out from a slowly drifting centre.
Hot off the press
How to use it
- Paste the snippet just before
</head>. - Add
data-halftone-waveto a section with a plain background colour.
Options
Add any of these attributes to the same element.
data-color | Dot colour. Default pink #db2777. |
data-size | Gap between dots, in px. Default 16. |
data-angle | Direction the waves roll, in degrees. Default 30. |
data-speed | Speed multiplier. Default 1. |
data-fade | Shrink the dots towards the middle, so text stays clear. |
It’s drawn on a transparent canvas behind your content, sharp on high-density screens, and paused when off screen. With reduced motion it holds still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Halftone Wave: a field of halftone dots, like an old print magnified,
// swells and shrinks in waves that roll across the section and ripple out
// from a drifting centre. Drawn on a transparent canvas behind your
// content, and paused when off screen.
// Usage: <section data-halftone-wave style="background:#fde68a">…</section>
// Optional: data-color (dot colour, default #db2777), data-size (gap
// between dots in px, default 16), data-speed (default 1), data-angle
// (direction the waves roll, in degrees, default 30), data-fade (fade the
// dots out towards the middle so text stays clear).
var DEFAULTS = { color: '#db2777', size: 16, speed: 1, angle: 30 };
var css =
'[data-halftone-wave]{position:relative;isolation:isolate;overflow:hidden}' +
'.htw-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var color = d.color || DEFAULTS.color;
var size = +d.size || DEFAULTS.size;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var angle = ((d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI) / 180;
var fade = d.fade !== undefined && d.fade !== 'false';
var dx = Math.cos(angle), dy = Math.sin(angle);
var canvas = document.createElement('canvas');
canvas.className = 'htw-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1, t = 2.5;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
draw();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = color;
var max = size * 0.62; // a full dot just touches its neighbours
var rowH = size * 0.866; // rows offset like a printer's screen
// A centre that drifts slowly about, sending rings outwards.
var cx = w * (0.5 + 0.3 * Math.sin(t * 0.13)), cy = h * (0.5 + 0.3 * Math.cos(t * 0.11));
var k = (2 * Math.PI) / (size * 14);
ctx.beginPath();
for (var r = 0, y = size / 2; y < h + size; r++, y += rowH) {
for (var x = (r & 1 ? size / 2 : 0); x < w + size; x += size) {
var along = (x * dx + y * dy) * k;
var dist = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) * k * 0.8;
var v = 0.5 + 0.32 * Math.sin(along - t * 1.6) + 0.22 * Math.sin(dist - t * 1.1);
if (fade) {
var fx = (x - w / 2) / (w / 2), fy = (y - h / 2) / (h / 2);
v *= Math.min(1, 0.15 + Math.sqrt(fx * fx + fy * fy) * 0.95);
}
var rad = max * v * v * 1.15;
if (rad < 0.4) continue;
ctx.moveTo(x + rad, y);
ctx.arc(x, y, rad, 0, Math.PI * 2);
}
}
ctx.fill();
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += ((last ? Math.min(50, now - last) : 16) / 1000) * speed;
last = now;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-halftone-wave]').forEach(function (el) {
if (el._htw) return;
el._htw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-halftone-wave data-fade style="background: #fde68a">
<h1>Hot off the press</h1>
</section>