Light Leak Transition
A warm light leak flares across the change, like light spilling into an old film camera: glowing blobs of orange, gold and red drift over the view, wash it out to a warm glare, and the next view comes up out of the glow. As a page transition the page flares out when a link is clicked and the next page appears as the glare drifts away. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-light-leak-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-light-leak-transition="#hex" | The main colour of the leak. Default #ff7a1a. |
data-direction | Which way the leak travels: right or left. Default right. |
data-duration | Time for the whole flare. Default 1100 ms on a page (half leaving, half arriving), 1300 ms in a slideshow. |
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 lets the glare drift off again. Put data-light-leak-transition on any other element with two or more children and it flares 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 () {
// Light Leak Transition: a warm light leak flares across the change, like
// light spilling into an old film camera. Glowing blobs of orange, gold and
// red sweep over the view, wash it out to a warm glare, and the next view
// comes up out of the glow as they drift off the other side.
// Usage: <body data-light-leak-transition> (add the snippet to every page)
// Optional: data-light-leak-transition="#hex" (the main colour of the leak,
// default #ff7a1a), data-direction ("right", the default: the leak travels
// left to right; or "left"), data-duration (ms for the whole flare:
// default 1100 on a page, split across leaving and arriving; 1300 for a
// slideshow change).
// Contained mode: put data-light-leak-transition on any other element with
// two or more children. Every data-interval ms (default 2200) the leak
// flares across and the next child appears.
var DEFAULTS = { color: '#ff7a1a', direction: 'right', duration: 1100, slideDuration: 1300, interval: 2200 };
var KEY = 'animacious-light-leak';
var css =
'html.llt-pre body{opacity:0}' +
'.llt-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
'.llt-contained{position:relative;overflow:hidden;display:grid}' +
'.llt-contained>*{grid-area:1/1}' +
'.llt-contained>.llt-fx{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
'.llt-wash,.llt-glow{position:absolute;inset:0}' +
'.llt-glow{mix-blend-mode:screen}' +
'.llt-blob{position:absolute;left:0;top:0;border-radius:50%;will-change:transform,opacity}' +
'.llt-streak{position:absolute;left:0;top:0;border-radius:50%;will-change:transform,opacity}';
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('llt-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('llt-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 rgba(hex, a) {
var m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 'rgba(255,122,26,' + a + ')';
var h = m[1].length === 3 ? m[1].replace(/./g, '$&$&') : m[1];
var n = parseInt(h, 16);
return 'rgba(' + (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255) + ',' + a + ')';
}
// The leak itself: a warm wash plus glowing blobs and a thin anamorphic
// streak that drift across. Returns paint(t) for t from 0 to 1, with the
// glare at its brightest at t = 0.5. `full` makes the glare opaque at the
// peak (needed on a page, so the swap can't be seen).
function leak(parent, color, dir, full) {
var fx = document.createElement('div');
fx.className = parent === document.body ? 'llt-layer' : 'llt-fx';
fx.setAttribute('aria-hidden', 'true');
var wash = document.createElement('div');
wash.className = 'llt-wash';
wash.style.background = 'radial-gradient(120% 90% at 50% 45%,#fff6e8 0%,' + rgba(color, 1) + ' 120%)';
var glow = document.createElement('div');
glow.className = 'llt-glow';
fx.appendChild(wash);
fx.appendChild(glow);
// [colour, size (x the longer side), height in the view (0-1), speed, lag]
var spec = [
[rgba(color, 0.95), 1.05, 0.38, 1.0, 0],
['rgba(255,209,102,0.9)', 0.7, 0.72, 1.25, 0.08],
['rgba(255,61,110,0.75)', 0.85, 0.12, 0.85, 0.04],
['rgba(255,236,190,0.9)', 0.45, 0.55, 1.4, 0.12]
];
var blobs = spec.map(function (s) {
var b = document.createElement('i');
b.className = 'llt-blob';
b.style.background = 'radial-gradient(circle,' + s[0] + ' 0%,' + s[0].replace(/[\d.]+\)$/, '0.35)') + ' 38%,transparent 70%)';
glow.appendChild(b);
return { el: b, s: s };
});
var streak = document.createElement('i');
streak.className = 'llt-streak';
streak.style.background = 'radial-gradient(closest-side,rgba(255,250,235,.95),rgba(255,190,120,.4) 45%,transparent)';
glow.appendChild(streak);
parent.appendChild(fx);
return {
fx: fx,
paint: function (t) {
var w = fx.clientWidth || innerWidth, h = fx.clientHeight || innerHeight;
var big = Math.max(w, h);
var sign = dir === 'left' ? -1 : 1;
var bell = Math.sin(Math.PI * Math.min(1, Math.max(0, t)));
blobs.forEach(function (b) {
var d = b.s[1] * big;
// Each blob travels from off one edge to off the other, at its own pace.
var p = Math.min(1, Math.max(0, (t - b.s[4]) * b.s[3] / (1 - b.s[4] * 0.5)));
var x = -d + (w + d) * p;
if (sign < 0) x = w - x - d;
var y = b.s[2] * h - d / 2 + Math.sin(p * Math.PI * 1.3) * h * 0.08;
b.el.style.width = b.el.style.height = d.toFixed(0) + 'px';
b.el.style.transform = 'translate(' + (x).toFixed(1) + 'px,' + y.toFixed(1) + 'px) scale(' + (0.8 + 0.4 * bell).toFixed(3) + ')';
b.el.style.opacity = Math.pow(bell, 0.6).toFixed(3);
});
var sw = w * 1.6, sh = Math.max(10, h * 0.06);
var sx = (sign > 0 ? -sw + (w + sw) * t : w - (w + sw) * t * 1.0);
streak.style.width = sw.toFixed(0) + 'px';
streak.style.height = sh.toFixed(0) + 'px';
streak.style.transform = 'translate(' + sx.toFixed(1) + 'px,' + (h * 0.42 - sh / 2).toFixed(1) + 'px)';
streak.style.opacity = (bell * 0.9).toFixed(3);
// The wash: a quick bloom round the middle of the flare.
var g = Math.exp(-Math.pow((t - 0.5) / (full ? 0.2 : 0.13), 2));
wash.style.opacity = (full ? Math.min(1, g * 1.08) : g * 0.94).toFixed(3);
}
};
}
// ---------- Page mode: the leak flares up, the page changes in the glare ----------
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var color = body.getAttribute('data-light-leak-transition') || DEFAULTS.color;
var dir = body.dataset.direction || DEFAULTS.direction;
var L = null, anim, trip = 0;
// Leaving plays the first half of the flare, up to the glare; arriving
// plays the second half, out of it.
function run(out, done) {
if (anim) anim.pause();
if (L) L.fx.remove();
L = leak(body, color, dir, true);
anim = tween(duration / 2, out ? 'in(1.6)' : 'out(1.6)', function (t) { L.paint(out ? t / 2 : 0.5 + t / 2); }, function () {
if (!out) { L.fx.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 / 2 + 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 leak flares across a slideshow ----------
function containedMode(el, still) {
el.classList.add('llt-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.slideDuration;
var color = el.getAttribute('data-light-leak-transition') || DEFAULTS.color;
var dir = el.dataset.direction || DEFAULTS.direction;
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 n = (i + 1) % slides.length, from = slides[i], to = slides[n], cut = false;
var L = leak(el, color, dir, false);
tween(duration, 'inOut(1.4)', function (t) {
L.paint(t);
if (!cut && t >= 0.5) {
cut = true;
from.style.visibility = 'hidden';
from.style.filter = '';
to.style.visibility = 'visible';
}
// The view under the leak warms up and burns out a little.
var k = Math.exp(-Math.pow((t - 0.5) / 0.2, 2));
(cut ? to : from).style.filter = k > 0.01 ? 'brightness(' + (1 + 0.5 * k).toFixed(3) + ') sepia(' + (0.45 * k).toFixed(3) + ') saturate(' + (1 + 0.4 * k).toFixed(3) + ')' : '';
}, function () {
to.style.filter = '';
L.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-light-leak-transition]').forEach(function (el) {
if (el._llt) return;
el._llt = 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-light-leak-transition>
…
</body>