Ink Splash Transition
A page transition: when a link is clicked, ink splatters out from the spot you clicked, spikes and droplets flying, and spreads until it covers the screen. On the next page the ink drains away down the screen, leaving drips behind. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-ink-splash-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-ink-splash-transition="#hex" | Ink colour. Default #1e1b4b. |
data-duration | Time for each splash or drain. Default 850 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 drains the ink away again. Put data-ink-splash-transition 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 () {
// Ink Splash Transition: a page transition. When a link is clicked an ink
// splatter bursts from the spot you clicked, spikes and droplets flying,
// and spreads until it covers the screen; on the next page the ink drains
// away down the screen in drips.
// Usage: <body data-ink-splash-transition> (add the snippet to every page)
// Optional: data-ink-splash-transition="#hex" (ink colour, default
// #1e1b4b), data-duration (ms for each half, default 850).
// Contained mode: put data-ink-splash-transition on any other element with
// two or more children and it splashes between them every data-interval ms
// (default 2200).
var DEFAULTS = { color: '#1e1b4b', duration: 850, interval: 2200 };
var css =
'.isp-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;visibility:hidden;display:block}' +
'.isp-contained{position:relative;overflow:hidden;display:grid}' +
'.isp-contained>*:not(.isp-layer){grid-area:1/1}' +
'.isp-contained>.isp-layer{position:absolute;z-index:2}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function layer(parent, color) {
var c = document.createElement('canvas');
c.className = 'isp-layer';
c.setAttribute('aria-hidden', 'true');
parent.appendChild(c);
var L = { c: c, ctx: c.getContext('2d'), color: color, w: 0, h: 0 };
fit(L);
return L;
}
function fit(L) {
var w = L.c.clientWidth, h = L.c.clientHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
if (L.c.width !== Math.round(w * dpr) || L.c.height !== Math.round(h * dpr)) {
L.c.width = Math.round(w * dpr);
L.c.height = Math.round(h * dpr);
}
L.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
L.w = w;
L.h = h;
}
function rand(a, b) { return a + Math.random() * (b - a); }
// A fresh, random splatter: a ragged core with lumps round its edge,
// thin spikes that shoot out with a bead of ink at each tip, and loose
// droplets further out. Plus the runs left behind when it drains.
function splatter() {
var s = { edge: [], lobes: [], spikes: [], drops: [], drips: [] }, i;
for (i = 0; i < 22; i++) s.edge.push(rand(0.86, 1.1));
for (i = 0; i < 8; i++) s.lobes.push({ a: rand(0, Math.PI * 2), d: rand(0.75, 1.05), r: rand(0.22, 0.4) });
for (i = 0; i < 13; i++) s.spikes.push({ a: rand(0, Math.PI * 2), len: rand(1.35, 2.1), w: rand(0.08, 0.16) });
for (i = 0; i < 26; i++) s.drops.push({ a: rand(0, Math.PI * 2), d: rand(1.3, 2.8), r: rand(0.025, 0.08) });
for (i = 0; i < 12; i++) s.drips.push({ x: rand(0, 1), w: rand(0.012, 0.03), lag: rand(0.2, 1) });
return s;
}
// Spreading: `t` 0 to 1 grows the splash from (x, y) to cover everything.
function paintSplash(L, s, t, x, y) {
var ctx = L.ctx, w = L.w, h = L.h, i;
var far = Math.max(Math.hypot(x, y), Math.hypot(w - x, y), Math.hypot(x, h - y), Math.hypot(w - x, h - y)) / 0.86;
// Bursts out fast, then swells to fill.
var burst = 1 - Math.pow(1 - Math.min(1, t * 2.2), 3);
var core = far * (0.12 * burst + 0.88 * Math.pow(t, 2.2));
var unit = far * 0.16 * burst + core * 0.5;
ctx.beginPath();
var n = s.edge.length;
for (i = 0; i <= n; i++) {
var a = i / n * Math.PI * 2, r = core * s.edge[i % n];
if (i) ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); else ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
}
ctx.fill();
s.lobes.forEach(function (b) {
ctx.beginPath();
ctx.arc(x + Math.cos(b.a) * core * b.d, y + Math.sin(b.a) * core * b.d, core * b.r, 0, Math.PI * 2);
ctx.fill();
});
s.spikes.forEach(function (k) {
var len = Math.max(core, unit * k.len * 2.2), ca = Math.cos(k.a), sa = Math.sin(k.a);
var bw = far * k.w * 0.22 * burst, px = -sa, py = ca;
var bx = x + ca * core * 0.6, by = y + sa * core * 0.6, tx = x + ca * len, ty = y + sa * len;
ctx.beginPath();
ctx.moveTo(bx + px * bw, by + py * bw);
ctx.lineTo(tx, ty);
ctx.lineTo(bx - px * bw, by - py * bw);
ctx.fill();
ctx.beginPath();
ctx.arc(tx, ty, bw * 0.75, 0, Math.PI * 2);
ctx.fill();
});
s.drops.forEach(function (d) {
var dist = unit * d.d * 2.4;
if (dist < core) return;
ctx.beginPath();
ctx.arc(x + Math.cos(d.a) * dist, y + Math.sin(d.a) * dist, Math.max(1.5, far * d.r * burst * 0.5), 0, Math.PI * 2);
ctx.fill();
});
}
// Draining: `t` 0 to 1 slides the ink down and off, leaving drips that
// cling and trail behind its top edge.
function paintDrain(L, s, t) {
var ctx = L.ctx, w = L.w, h = L.h;
var trail = h * 0.45, wave = Math.max(6, h * 0.02);
var fall = t * t * (3 - 2 * t);
var top = -wave + fall * (h + trail + wave * 2);
ctx.beginPath();
ctx.moveTo(0, h);
for (var x = 0; x <= w + 8; x += 8) ctx.lineTo(x, top + Math.sin(x / w * 9 + t * 4) * wave * Math.min(1, t * 6));
ctx.lineTo(w, h);
ctx.fill();
s.drips.forEach(function (d) {
// Each run narrows as it climbs and ends in a rounded bead.
var dw = Math.max(3, w * d.w), dx = d.x * w, len = trail * d.lag * Math.min(1, t * 3);
if (len < 2) return;
var y0 = top - len, tip = dw * 0.4;
ctx.beginPath();
ctx.moveTo(dx - dw * 1.6, top + wave);
ctx.quadraticCurveTo(dx - dw * 0.5, top, dx - tip, y0 + tip);
ctx.arc(dx, y0 + tip, tip, Math.PI, 0);
ctx.quadraticCurveTo(dx + dw * 0.5, top, dx + dw * 1.6, top + wave);
ctx.fill();
});
}
function run(L, cover, fx, fy, duration, done) {
if (L.anim) L.anim.pause();
fit(L);
var s = { t: 0 }, shape = splatter();
var draw = function (t) {
L.ctx.clearRect(0, 0, L.w, L.h);
L.ctx.fillStyle = L.color;
if (cover) paintSplash(L, shape, t, fx * L.w, fy * L.h); else paintDrain(L, shape, t);
};
L.c.style.visibility = 'visible';
draw(0);
L.anim = anime.animate(s, {
t: 1,
duration: duration,
ease: 'linear',
onUpdate: function () { draw(s.t); },
onComplete: function () {
draw(1);
if (!cover) L.c.style.visibility = 'hidden';
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 pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var L = layer(body, body.getAttribute('data-ink-splash-transition') || DEFAULTS.color);
var trip = 0;
run(L, false, 0.5, 0.5, duration);
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 {
run(L, true, e.clientX / innerWidth || 0.5, e.clientY / innerHeight || 0.5, duration, go);
setTimeout(go, duration + 400);
} catch (err) { go(); }
});
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
run(L, false, 0.5, 0.5, duration);
});
}
function containedMode(el, reduced) {
el.classList.add('isp-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var L = layer(el, el.getAttribute('data-ink-splash-transition') || DEFAULTS.color);
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (reduced) return;
var timer = anime.createTimer({
duration: (+el.dataset.interval || DEFAULTS.interval) + duration * 2 + 150,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
run(L, true, 0.25 + Math.random() * 0.5, 0.25 + Math.random() * 0.5, duration, function () {
slides[i].style.visibility = 'hidden';
i = (i + 1) % slides.length;
slides[i].style.visibility = 'visible';
setTimeout(function () { run(L, false, 0.5, 0.5, duration); }, 150);
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('[data-ink-splash-transition]').forEach(function (el) {
if (el._isp) return;
el._isp = true;
if (el === document.body) { if (!reduced) pageMode(el); }
else if (el.children.length > 1) containedMode(el, reduced);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-ink-splash-transition>
…
</body>