Pixel Dissolve Transition
A page transition: when a link is clicked, square pixels of colour pop up at random until the page is covered, and on the next page they clear away at random to reveal it. As a slideshow, the picture itself breaks into pixels that vanish one by one. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-pixel-dissolve-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-pixel-dissolve-transition="#hex" | Pixel colour. Default #6a0dad. |
data-size | Rough pixel size. Default 40 px (a little smaller in small slideshows). |
data-duration | Time for each half. Default 700 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 clears the pixels again. Put data-pixel-dissolve-transition on any other element with two or more children and each child dissolves into the next, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pixel Dissolve Transition: the view breaks into square pixels that clear
// in random order. As a page transition, pixels of colour pop in at random
// to cover the page when a link is clicked, and on the next page they clear
// away at random to reveal it.
// Usage: <body data-pixel-dissolve-transition> (add the snippet to every page)
// Optional: data-pixel-dissolve-transition="#hex" (pixel colour, default
// #6a0dad), data-size (rough pixel size in px, default 40),
// data-duration (ms for each half, default 700).
// Contained mode: put data-pixel-dissolve-transition on any other element
// with two or more children. Every data-interval ms (default 2200) the
// current child breaks into pixels that vanish in random order, revealing
// the next one underneath.
var DEFAULTS = { color: '#6a0dad', size: 40, duration: 700, interval: 2200 };
var css =
'.pxd-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;visibility:hidden;display:block}' +
'.pxd-contained{position:relative;overflow:hidden;display:grid}' +
'.pxd-contained>*{grid-area:1/1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// A grid of square-ish cells over a w x h box, each with a random moment
// (0 to 1) at which it flips.
function grid(w, h, size) {
var cols = Math.max(2, Math.round(w / size)), rows = Math.max(2, Math.round(h / size)), at = [];
for (var i = 0; i < cols * rows; i++) at.push(Math.random());
return { cols: cols, rows: rows, at: at };
}
// ---------- Page mode: coloured pixels on a canvas ----------
function layer(body, color) {
var c = document.createElement('canvas');
c.className = 'pxd-layer';
c.setAttribute('aria-hidden', 'true');
body.appendChild(c);
return { c: c, ctx: c.getContext('2d'), color: color };
}
// Cover: pixels pop in at random. Uncover: they clear at random.
function run(L, cover, size, duration, done) {
if (L.anim) L.anim.pause();
var w = innerWidth, h = innerHeight, dpr = Math.min(2, window.devicePixelRatio || 1);
L.c.width = Math.round(w * dpr);
L.c.height = Math.round(h * dpr);
L.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var g = grid(w, h, size), cw = w / g.cols, ch = h / g.rows, s = { t: 0 };
var draw = function (t) {
L.ctx.clearRect(0, 0, w, h);
L.ctx.fillStyle = L.color;
for (var i = 0; i < g.at.length; i++) {
if ((g.at[i] < t) !== cover) continue;
var c = i % g.cols, r = (i / g.cols) | 0;
// Round outwards so neighbouring pixels leave no hairline gaps.
L.ctx.fillRect(Math.floor(c * cw), Math.floor(r * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
}
};
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.01);
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 size = +body.dataset.size || DEFAULTS.size;
var L = layer(body, body.getAttribute('data-pixel-dissolve-transition') || DEFAULTS.color);
var trip = 0;
run(L, false, size, 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, size, duration, go);
setTimeout(go, duration + 400);
} catch (err) { go(); }
});
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
run(L, false, size, duration);
});
}
// ---------- Contained mode: the real view breaks up ----------
// A mask that keeps only the cells still standing, drawn as crisp SVG
// squares so it scales to any size without blurring.
function mask(g, t) {
var d = '';
for (var i = 0; i < g.at.length; i++) {
if (g.at[i] < t) continue;
d += 'M' + (i % g.cols) + ' ' + ((i / g.cols) | 0) + 'h1v1h-1z';
}
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + g.cols + ' ' + g.rows +
'" preserveAspectRatio="none" shape-rendering="crispEdges"><path d="' + d + '"/></svg>';
return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")';
}
function setMask(node, m) {
node.style.webkitMaskImage = m;
node.style.maskImage = m;
var size = m === 'none' ? '' : '100% 100%';
node.style.webkitMaskSize = size;
node.style.maskSize = size;
}
function containedMode(el, reduced) {
el.classList.add('pxd-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (reduced) return;
var size = +el.dataset.size || Math.max(18, Math.min(DEFAULTS.size, el.clientWidth / 16));
var timer = anime.createTimer({
duration: (+el.dataset.interval || DEFAULTS.interval) + duration,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
var from = slides[i], to = slides[(i + 1) % slides.length];
var g = grid(el.clientWidth, el.clientHeight, +el.dataset.size || size), s = { t: 0 }, shown = -1;
// The old view sits on top and loses pixels; the new one waits beneath.
from.style.zIndex = 1;
to.style.zIndex = 0;
to.style.visibility = 'visible';
anime.animate(s, {
t: 1,
duration: duration,
ease: 'linear',
onUpdate: function () {
// Rebuild the mask at most 60 times over the whole dissolve.
var step = Math.round(s.t * 60);
if (step === shown) return;
shown = step;
setMask(from, mask(g, s.t));
},
onComplete: function () {
from.style.visibility = 'hidden';
from.style.zIndex = to.style.zIndex = '';
setMask(from, 'none');
i = (i + 1) % slides.length;
}
});
}
});
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-pixel-dissolve-transition]').forEach(function (el) {
if (el._pxd) return;
el._pxd = 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-pixel-dissolve-transition>
…
</body>