Swirl Transition
The view twists into a swirl, its middle turning further than its edges like water round a plughole, and untwists as the next view, still turning the same way until it settles. As a page transition, spiral arms of colour swirl in and thicken until they cover the page, then keep turning and thin away on the next page. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-swirl-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-swirl-transition="#hex" | On a page, the colour of the spiral arms. Default #6a0dad. |
data-turns | How far the middle turns at the height of the swirl. Default 1.25 turns. |
data-rings | Contained mode only: how many rings the view twists in. More is smoother but heavier. Default 18. |
data-duration | Time for each half of a page change. Default 700 ms (1500 ms for a whole slideshow change). |
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 swirls the arms away again. Put data-swirl-transition on any other element with two or more children and the real views twist into each other, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Swirl Transition: the view twists into a swirl, the middle turning
// further than the edges, and untwists as the next view, still turning the
// same way until it settles.
// Usage: <body data-swirl-transition> (add the snippet to every page)
// Optional: data-swirl-transition="#hex" (on a page, the colour of the
// spiral arms that swirl in over it, default #6a0dad), data-turns (how far
// the middle turns at the height of the swirl, default 1.25), data-rings
// (how many rings the view twists in, default 18), data-duration (ms:
// default 700 for each half of a page change, 1500 for a slideshow change).
// Contained mode: put data-swirl-transition on any other element with two
// or more children and it swirls from one child to the next every
// data-interval ms (default 2200).
var DEFAULTS = { color: '#6a0dad', turns: 1.25, rings: 18, duration: 700, slideDuration: 1500, interval: 2200 };
var KEY = 'animacious-swirl';
var css =
'html.swt-pre body{opacity:0}' +
'.swt-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
'.swt-contained{position:relative;overflow:hidden;display:grid}' +
'.swt-contained>*{grid-area:1/1}' +
'.swt-contained>.swt-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
'.swt-view{position:absolute;inset:0}' +
'.swt-ring{position:absolute;left:0;top:0;margin:0;max-width:none;will-change:transform}';
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('swt-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('swt-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;
}
// How much a point at fraction `u` of the way out from the middle turns,
// relative to the middle: all of it at the centre, none at the corners.
function falloff(u) { return Math.pow(Math.max(0, 1 - u), 1.7); }
// ---------- Page mode: spiral arms swirl in to cover the page ----------
function paintArms(ctx, w, h, color, cover, spin, turns) {
var cx = w / 2, cy = h / 2, R = Math.hypot(w, h) / 2 + 4, N = 6;
ctx.clearRect(0, 0, w, h);
if (cover <= 0) return;
ctx.fillStyle = color;
if (cover >= 1) { ctx.fillRect(0, 0, w, h); return; }
var half = Math.PI / N * cover * 1.02, steps = 40;
ctx.beginPath();
for (var a = 0; a < N; a++) {
var base = a * Math.PI * 2 / N + spin;
for (var s = 0; s <= steps; s++) {
var u = s / steps, th = base + turns * Math.PI * 2 * falloff(u) - half;
if (s) ctx.lineTo(cx + Math.cos(th) * R * u, cy + Math.sin(th) * R * u);
else ctx.moveTo(cx, cy);
}
for (s = steps; s >= 0; s--) {
var v = s / steps, tv = base + turns * Math.PI * 2 * falloff(v) + half;
ctx.lineTo(cx + Math.cos(tv) * R * v, cy + Math.sin(tv) * R * v);
}
ctx.closePath();
}
ctx.fill();
}
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var color = body.getAttribute('data-swirl-transition') || DEFAULTS.color;
var turns = +body.dataset.turns || DEFAULTS.turns;
var c = null, anim, trip = 0;
// Leaving: the arms sweep round, fattening until they meet. Arriving:
// they keep turning the same way and thin out to nothing.
function run(out, done) {
if (anim) anim.pause();
if (c) c.remove();
c = document.createElement('canvas');
c.className = 'swt-layer';
c.setAttribute('aria-hidden', 'true');
body.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 mine = c;
anim = tween(duration, out ? 'in(2)' : 'out(2)', function (t) {
var k = out ? t : 1 + t;
paintArms(ctx, w, h, color, out ? t : 1 - t, k * Math.PI * 0.9, turns * 0.6 * (out ? t : 1 - t) + 0.25);
}, function () {
if (!out) { mine.remove(); if (c === mine) c = 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: the real views twist ----------
// A copy of `node` cut into concentric rings, each a full copy clipped to
// a circle, smaller ones stacked on top. Returns twist(angle): the middle
// turns by `angle` degrees and each ring a little less the further out it is.
function rings(fx, node, w, h, count) {
var view = document.createElement('div');
view.className = 'swt-view';
var R = Math.hypot(w, h) / 2, list = [];
for (var k = count; k >= 1; k--) {
var c = node.cloneNode(true);
c.removeAttribute('id');
c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
c.classList.add('swt-ring');
c.style.width = w + 'px';
c.style.height = h + 'px';
c.style.visibility = 'visible';
var r = R * k / count;
// The outermost copy is left whole so the corners are always covered.
if (k < count) c.style.clipPath = 'circle(' + (r + 0.5).toFixed(1) + 'px at 50% 50%)';
view.appendChild(c);
list.push({ el: c, f: falloff((k - 0.5) / count) });
}
fx.appendChild(view);
return {
view: view,
twist: function (deg) {
list.forEach(function (g) { g.el.style.transform = 'rotate(' + (deg * g.f).toFixed(2) + 'deg)'; });
}
};
}
function containedMode(el, still) {
el.classList.add('swt-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.slideDuration;
var turns = +el.dataset.turns || DEFAULTS.turns;
var count = Math.max(4, Math.min(40, +el.dataset.rings || DEFAULTS.rings));
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,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
var fx = document.createElement('div');
fx.className = 'swt-fx';
fx.setAttribute('aria-hidden', 'true');
el.appendChild(fx);
var A = rings(fx, from, w, h, count), B = rings(fx, to, w, h, count);
var peak = turns * 360;
from.style.visibility = 'hidden';
tween(duration, 'linear', function (t) {
// Twist up with ease-in, untwist with ease-out, turning one way throughout.
var a = t < 0.5 ? Math.pow(t * 2, 2.2) : 1 + (1 - Math.pow(1 - (t - 0.5) * 2, 2.2));
A.twist(peak * a);
B.twist(peak * (a - 2));
// The two swap in a quick cross-fade at the height of the swirl.
var mix = Math.max(0, Math.min(1, (t - 0.4) / 0.2));
B.view.style.opacity = mix.toFixed(3);
A.view.style.opacity = Math.max(0, 1 - Math.max(0, (t - 0.55) / 0.1)).toFixed(3);
// A little zoom and blur hide the seams when it is most twisted.
var k = Math.sin(Math.PI * t);
fx.style.transform = 'scale(' + (1 + 0.12 * k).toFixed(4) + ')';
fx.style.filter = k > 0.3 ? 'blur(' + ((k - 0.3) * 2.2).toFixed(2) + 'px)' : '';
}, function () {
to.style.visibility = 'visible';
fx.remove();
i = n;
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-swirl-transition]').forEach(function (el) {
if (el._swt) return;
el._swt = 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-swirl-transition>
…
</body>