Whip Pan
A fast, motion-blurred slide from one view to the next, like a camera swung hard to one side: the views smear sideways as they race past, then snap sharp as the new one lands. As a page transition the page whips away when a link is clicked and the next page whips in from the other side. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-whip-panto each page’s<body>.
Options
Add any of these attributes to the same element.
data-whip-pan="#hex" | The colour behind the page while it whips. Default #0f0b1a. |
data-direction | Which way the view travels: left, right, up or down. Default left. |
data-blur | Strength of the motion blur; 0 turns it off. Default 1. |
data-duration | Time for each half of a page change. Default 380 ms (650 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 whips the page in again. Put data-whip-pan on any other element with two or more children and it whips from one child to the next, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Whip Pan: a fast, motion-blurred slide from one view to the next, like a
// camera swung hard to one side. The views smear sideways as they race past
// and snap sharp again as the new one lands.
// Usage: <body data-whip-pan> (add the snippet to every page)
// Optional: data-whip-pan="#hex" (colour behind the page while it whips,
// default #0f0b1a), data-direction (left, right, up or down: which way the
// view travels, default left), data-blur (strength of the smear, default 1),
// data-duration (ms; default 380 for each half on a page, 650 for a
// slideshow change).
// Contained mode: put data-whip-pan on any other element with two or more
// children. Every data-interval ms (default 2200) it whips to the next child.
var DEFAULTS = { color: '#0f0b1a', direction: 'left', blur: 1, duration: 380, slideDuration: 650, interval: 2200 };
var KEY = 'animacious-whip-pan';
var DIRS = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
var css =
'html.whp-pre body{opacity:0}' +
'.whp-contained{position:relative;overflow:hidden;display:grid}' +
'.whp-contained>*{grid-area:1/1}';
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('whp-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('whp-pre');
setTimeout(unhide, 3000);
}
// A blur in one direction only, as an SVG filter: its amount is changed
// every frame to follow the speed.
var NS = 'http://www.w3.org/2000/svg';
var count = 0;
function streak() {
var id = 'whp-blur-' + (++count);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="0 0" edgeMode="duplicate"/></filter>';
document.body.appendChild(svg);
var blur = svg.querySelector('feGaussianBlur');
return {
url: 'url(#' + id + ')',
set: function (x, y) { blur.setAttribute('stdDeviation', x.toFixed(1) + ' ' + y.toFixed(1)); }
};
}
// Slow away, a violent middle, a hard landing: an in-out curve of power 4,
// and its slope, which sets the blur.
function ease(t) { return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2; }
function speed(t) { return t < 0.5 ? 32 * t * t * t : 4 * Math.pow(-2 * t + 2, 3); }
function tween(duration, paint, done) {
var s = { t: 0 };
paint(0);
return anime.animate(s, {
t: 1, duration: duration, ease: 'linear',
onUpdate: function () { paint(s.t); },
onComplete: function () { paint(1); if (done) done(); }
});
}
function dirOf(node) { return DIRS[node.dataset.direction] || DIRS[DEFAULTS.direction]; }
function blurOf(node) { var b = parseFloat(node.dataset.blur); return isNaN(b) ? DEFAULTS.blur : Math.max(0, b); }
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 color = body.getAttribute('data-whip-pan') || DEFAULTS.color;
var d = dirOf(body), power = blurOf(body), html = document.documentElement;
var fx = streak(), anim, trip = 0, saved = null;
// Leaving: the page accelerates away. Arriving: it races in from the
// other side and brakes hard. Each is one half of the whole curve.
function run(out, done) {
if (anim) anim.pause();
if (saved === null) saved = { bg: html.style.background, ov: html.style.overflow };
html.style.background = color;
html.style.overflow = 'hidden';
var W = innerWidth, H = innerHeight;
anim = tween(duration, function (t) {
var u = out ? t / 2 : 0.5 + t / 2;
var p = ease(u), v = speed(u);
var off = out ? -p : 1 - p;
body.style.transform = 'translate(' + (-d[0] * off * W).toFixed(1) + 'px,' + (-d[1] * off * H).toFixed(1) + 'px)';
fx.set(Math.abs(d[0]) * v * W * 0.01 * power, Math.abs(d[1]) * v * H * 0.01 * power);
body.style.filter = v > 0.05 ? fx.url : '';
}, function () {
if (!out) {
body.style.transform = body.style.filter = '';
html.style.background = saved.bg;
html.style.overflow = saved.ov;
saved = 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);
});
}
function containedMode(el, still) {
el.classList.add('whp-contained');
el.style.background = el.getAttribute('data-whip-pan') || DEFAULTS.color;
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.slideDuration;
var d = dirOf(el), power = blurOf(el);
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (still) return;
var fx = streak();
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;
var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
to.style.visibility = 'visible';
// The two views travel as one strip, the new one right behind the old.
tween(duration, function (t) {
var p = ease(t), v = speed(t);
var bx = Math.abs(d[0]) * v * W * 0.01 * power, by = Math.abs(d[1]) * v * H * 0.01 * power;
fx.set(bx, by);
var f = v > 0.05 ? fx.url : '';
from.style.transform = 'translate(' + (d[0] * p * W).toFixed(1) + 'px,' + (d[1] * p * H).toFixed(1) + 'px)';
to.style.transform = 'translate(' + (d[0] * (p - 1) * W).toFixed(1) + 'px,' + (d[1] * (p - 1) * H).toFixed(1) + 'px)';
from.style.filter = to.style.filter = f;
}, function () {
from.style.visibility = 'hidden';
from.style.transform = from.style.filter = '';
to.style.transform = to.style.filter = '';
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-whip-pan]').forEach(function (el) {
if (el._whp) return;
el._whp = 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-whip-pan>
…
</body>