Brush Stroke Wipe
A page transition: when a link is clicked, broad strokes of paint sweep across the screen one under another, back and forth, until the paint covers it. On the next page the strokes are wiped away the way they went on, their dry, streaky edges trailing behind. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-brush-stroke-wipeto each page’s<body>.
Options
Add any of these attributes to the same element.
data-brush-stroke-wipe="#hex" | Paint colour. Default #6a0dad. |
data-strokes | How many strokes it takes to cover. Default: about one per 140 px of height, at least 3. |
data-duration | Time to paint the screen, and again to wipe it clean. Default 900 ms. |
data-interval | Contained mode only: time each slide stays. Default 2200 ms. |
Every change gets freshly streaked strokes. 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 wipes the paint away again. Put data-brush-stroke-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 () {
// Brush Stroke Wipe: broad paint strokes sweep across the screen one under
// another, back and forth, until the paint covers it; then the strokes are
// wiped away the way they were laid down, uncovering the next view.
// Usage: <body data-brush-stroke-wipe> (add the snippet to every page)
// Optional: data-brush-stroke-wipe="#hex" (paint colour, default #6a0dad),
// data-strokes (how many strokes, default: about one per 140 px of
// height, at least 3), data-duration (ms to paint, and again to wipe away,
// default 900).
// Contained mode: put data-brush-stroke-wipe on any other element with two
// or more children and it paints between them every data-interval ms
// (default 2200).
var DEFAULTS = { color: '#6a0dad', duration: 900, interval: 2200 };
var KEY = 'animacious-brush-stroke';
var css =
'html.bsw-pre body{opacity:0}' +
'.bsw-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
'.bsw-contained{position:relative;overflow:hidden;display:grid}' +
'.bsw-contained>*:not(.bsw-layer){grid-area:1/1}' +
'.bsw-contained>.bsw-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('bsw-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('bsw-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;
}
function rand(a, b) { return a + Math.random() * (b - a); }
function rgb(hex) {
var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
if (!m) return [106, 13, 173];
var h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1], n = parseInt(h, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
// Each stroke is a bundle of overlapping bristle lines, every one a shade
// lighter or darker than the paint and starting and ending a little
// raggedly, so the stroke has a dry, streaky edge.
function layer(parent, color, count) {
var c = document.createElement('canvas');
c.className = 'bsw-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 base = rgb(color);
var n = count || Math.max(3, Math.round(h / 140));
var band = h / n, thick = band * 1.55, strokes = [];
for (var i = 0; i < n; i++) {
var bristles = [], k = Math.max(12, Math.round(thick / 6));
for (var b = 0; b < k; b++) {
var o = (b / (k - 1) - 0.5) * thick;
var edge = Math.abs(o) / (thick / 2); // 0 in the middle, 1 at the edges
var shade = rand(-0.14, 0.12);
var col = base.map(function (v) { return Math.round(shade > 0 ? v + (255 - v) * shade : v * (1 + shade)); });
bristles.push({
o: o,
width: thick / k * rand(1.9, 2.6) * (1 - 0.35 * edge),
color: 'rgb(' + col.join(',') + ')',
tail: rand(0, 0.03) + edge * edge * rand(0, 0.03), // edge bristles start late
head: rand(-0.05, 0.03) - edge * edge * rand(0, 0.06), // and give out early
amp: rand(0.5, 2.5),
phase: rand(0, 6.3)
});
}
strokes.push({
y: (i + 0.5) * band,
tilt: rand(-0.035, 0.035),
ltr: i % 2 === 0,
bristles: bristles
});
}
return { c: c, ctx: ctx, w: w, h: h, strokes: strokes, n: n };
}
// A stroke covers from u0 to u1 (0 to 1 along its length, in the
// direction it is painted).
function drawStroke(L, s, u0, u1) {
if (u1 <= u0) return;
var ctx = L.ctx, w = L.w, pad = w * 0.08, len = w + pad * 2;
ctx.lineCap = 'round';
s.bristles.forEach(function (b) {
var a = u0 > 0 ? u0 + b.tail * 0.6 : b.tail, z = Math.min(1, u1 + b.head);
if (z <= a) return;
ctx.strokeStyle = b.color;
ctx.lineWidth = b.width;
ctx.beginPath();
var steps = Math.max(2, Math.ceil((z - a) * len / 24));
for (var j = 0; j <= steps; j++) {
var u = a + (z - a) * j / steps;
var x = s.ltr ? -pad + u * len : w + pad - u * len;
var y = s.y + b.o + s.tilt * (x - w / 2) + Math.sin(u * 9 + b.phase) * b.amp;
if (j) ctx.lineTo(x, y); else ctx.moveTo(x, y);
}
ctx.stroke();
});
}
// `t` 0 to 1. Painting, each stroke's head runs across in turn; wiping,
// each stroke's tail follows the same path off the far side.
function paint(L, t, painting) {
L.ctx.clearRect(0, 0, L.w, L.h);
var win = 0.5, step = (1 - win) / Math.max(1, L.n - 1);
L.strokes.forEach(function (s, i) {
var p = Math.max(0, Math.min(1, (t - i * step) / win));
p = 1 - Math.pow(1 - p, 2.2);
if (painting) drawStroke(L, s, 0, p * 1.15);
else drawStroke(L, s, p * 1.08, 1);
});
}
// ---------- Page mode: paint over the page between links ----------
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var color = body.getAttribute('data-brush-stroke-wipe') || DEFAULTS.color;
var count = +body.dataset.strokes || 0;
var L = null, anim, trip = 0;
function run(out, done) {
if (anim) anim.pause();
if (L) L.c.remove();
L = layer(body, color, count);
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: paint from one child to the next ----------
function containedMode(el, still) {
el.classList.add('bsw-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var color = el.getAttribute('data-brush-stroke-wipe') || DEFAULTS.color;
var count = +el.dataset.strokes || 0;
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 + 100,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]') || !el.clientWidth) return;
var n = (i + 1) % slides.length;
var L = layer(el, color, count);
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(); });
}, 100);
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-brush-stroke-wipe]').forEach(function (el) {
if (el._bsw) return;
el._bsw = 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-brush-stroke-wipe>
…
</body>