Halftone Dot Wipe
A page transition: when a link is clicked, a wave of halftone dots sweeps across the screen, each dot swelling until they merge and cover it, like a printed picture going solid. On the next page the dots shrink away in the same direction. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-halftone-dot-wipeto each page’s<body>.
Options
Add any of these attributes to the same element.
data-halftone-dot-wipe="#hex" | Dot colour. Default #1e1b4b. |
data-size | Gap between dots. Default 26 px. |
data-direction | Where the wave comes from: diagonal, left, right, up, down or centre. Default diagonal. |
data-duration | Time to cover the screen, and again to clear it. Default 750 ms. |
data-interval | Contained mode only: time each slide stays. Default 2200 ms. |
Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Coming back with the Back button clears the dots away again. Put data-halftone-dot-wipe on any other element with two or more children and it becomes a slideshow.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Halftone Dot Wipe: a wave of growing halftone dots sweeps across the
// screen until they merge and cover it, then the dots shrink away in the
// same direction to show the next view, like a printed halftone screen.
// Usage: <body data-halftone-dot-wipe> (add the snippet to every page)
// Optional: data-halftone-dot-wipe="#hex" (dot colour, default #1e1b4b),
// data-size (gap between dots in px, default 26), data-direction
// ("diagonal", the default, "left", "right", "up", "down" or "centre"),
// data-duration (ms to cover, and again to clear, default 750).
// Contained mode: put data-halftone-dot-wipe on any other element with two
// or more children and it wipes between them every data-interval ms
// (default 2200).
var DEFAULTS = { color: '#1e1b4b', size: 26, direction: 'diagonal', duration: 750, interval: 2200 };
var KEY = 'animacious-halftone-dot';
var css =
'html.hdw-pre body{opacity:0}' +
'.hdw-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
'.hdw-contained{position:relative;overflow:hidden;display:grid}' +
'.hdw-contained>*:not(.hdw-layer){grid-area:1/1}' +
'.hdw-contained>.hdw-layer{position:absolute;z-index:2}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var arriving = false;
try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {}
function unhide() { document.documentElement.classList.remove('hdw-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('hdw-pre');
setTimeout(unhide, 3000);
}
function tween(duration, ease, paint, done) {
var s = { t: 0 };
paint(0);
return anime.animate(s, {
t: 1, duration: duration, ease: ease,
onUpdate: function () { paint(s.t); },
onComplete: function () { paint(1); if (done) done(); }
});
}
function linkFor(e) {
var a = e.target.closest && e.target.closest('a[href]');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
var url = new URL(a.href, location.href);
if (url.origin !== location.origin) return null;
if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
return url;
}
// A canvas of dots over `parent`. Each dot gets a start time from where it
// sits along the wave, so the dots swell in a sweeping band.
function layer(parent, opts) {
var c = document.createElement('canvas');
c.className = 'hdw-layer';
c.setAttribute('aria-hidden', 'true');
parent.appendChild(c);
var w = c.clientWidth, h = c.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
var ctx = c.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var gap = Math.max(10, opts.size);
var cols = Math.ceil(w / gap) + 1, rows = Math.ceil(h / (gap * 0.866)) + 1, dots = [];
for (var r = 0; r < rows; r++) {
for (var k = 0; k < cols + 1; k++) {
// Alternate rows shift half a step, like a real halftone screen.
var x = (k - (r % 2 ? 0.5 : 0)) * gap, y = r * gap * 0.866;
dots.push({ x: x, y: y, at: place(opts.direction, x / w, y / h) });
}
}
return { c: c, ctx: ctx, w: w, h: h, gap: gap, dots: dots, color: opts.color };
}
// 0 for the first dots the wave reaches, 1 for the last.
function place(dir, u, v) {
u = Math.max(0, Math.min(1, u));
v = Math.max(0, Math.min(1, v));
if (dir === 'left') return 1 - u;
if (dir === 'right') return u;
if (dir === 'up') return 1 - v;
if (dir === 'down') return v;
if (dir === 'centre' || dir === 'center') return Math.min(1, Math.hypot(u - 0.5, (v - 0.5) * 0.8) / 0.64);
return (u * 0.6 + v * 0.4);
}
// `t` 0 to 1. Covering, each dot grows from nothing to big enough to merge
// with its neighbours; clearing, it shrinks back to nothing.
var SPREAD = 0.55;
function paint(L, t, covering) {
var ctx = L.ctx, full = L.gap * 0.62;
ctx.clearRect(0, 0, L.w, L.h);
ctx.fillStyle = L.color;
var all = true;
ctx.beginPath();
for (var i = 0; i < L.dots.length; i++) {
var d = L.dots[i];
var p = (t - d.at * SPREAD) / (1 - SPREAD);
p = p < 0 ? 0 : p > 1 ? 1 : p;
p = p * p * (3 - 2 * p);
var r = full * (covering ? p : 1 - p);
if (r < full) all = false;
if (r < 0.3) continue;
ctx.moveTo(d.x + r, d.y);
ctx.arc(d.x, d.y, r, 0, Math.PI * 2);
}
ctx.fill();
// Fully grown dots leave tiny gaps between them, so fill those in at the end.
if (all) ctx.fillRect(0, 0, L.w, L.h);
}
function optsOf(node) {
return {
color: node.getAttribute('data-halftone-dot-wipe') || DEFAULTS.color,
size: +node.dataset.size || DEFAULTS.size,
direction: node.dataset.direction || DEFAULTS.direction
};
}
// ---------- Page mode: dots cover the page between links ----------
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var opts = optsOf(body);
var L = null, anim, trip = 0;
function run(out, done) {
if (anim) anim.pause();
if (L) L.c.remove();
L = layer(body, opts);
anim = tween(duration, 'linear', function (t) { paint(L, t, out); }, function () {
if (!out) { L.c.remove(); L = null; }
if (done) done();
});
}
if (arriving) run(false);
unhide();
document.addEventListener('click', function (e) {
var url = linkFor(e);
if (!url) return;
e.preventDefault();
var t = ++trip, gone = false;
var go = function () { if (gone || t !== trip) return; gone = true; location.href = url.href; };
try { sessionStorage.setItem(KEY, '1'); } catch (err) {}
setTimeout(go, duration + 400);
try { run(true, go); } catch (err) { go(); }
});
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
try { sessionStorage.removeItem(KEY); } catch (err) {}
run(false);
});
}
// ---------- Contained mode: dots wipe from one child to the next ----------
function containedMode(el, still) {
el.classList.add('hdw-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var opts = optsOf(el);
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (still) return;
var timer = anime.createTimer({
duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2 + 80,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]') || !el.clientWidth) return;
var n = (i + 1) % slides.length;
var L = layer(el, opts);
tween(duration, 'linear', function (t) { paint(L, t, true); }, function () {
slides[i].style.visibility = 'hidden';
slides[n].style.visibility = 'visible';
i = n;
setTimeout(function () {
tween(duration, 'linear', function (t) { paint(L, t, false); }, function () { L.c.remove(); });
}, 80);
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-halftone-dot-wipe]').forEach(function (el) {
if (el._hdw) return;
el._hdw = true;
if (el === document.body) { if (!still) pageMode(el); }
else if (el.children.length > 1) containedMode(el, still);
});
unhide();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-halftone-dot-wipe>
…
</body>