Shatter Transition
The view cracks from a point, splits into triangular shards and they tumble away under gravity, uncovering the next view. As a page transition the shards fly up and lock together over the page when a link is clicked, then crack and fall 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-shatter-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-shatter-transition="#hex" | Shard colour on a page change. Default #1e1b4b. |
data-size | Rough shard size. Default 150 px. |
data-duration | Time for the shards to fall. Default 1100 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 cracks the glass away again. Put data-shatter-transition on any other element with two or more children and each shard carries a real piece of the old view, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shatter Transition: the view breaks into shards that fall away. As a page
// transition, shards of colour fly up and lock together to cover the page
// when a link is clicked, and on the next page they crack apart and fall.
// Usage: <body data-shatter-transition> (add the snippet to every page)
// Optional: data-shatter-transition="#hex" (shard colour on a page,
// default #1e1b4b), data-size (rough shard size in px, default 150),
// data-duration (ms for the shards to fall, default 1100).
// Contained mode: put data-shatter-transition on any other element with two
// or more children. Every data-interval ms (default 2200) the current child
// cracks from a random point and falls away in pieces, uncovering the next.
var DEFAULTS = { color: '#1e1b4b', size: 150, duration: 1100, interval: 2200 };
var KEY = 'animacious-shatter';
var css =
'html.sht-pre body{opacity:0}' +
'.sht-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden}' +
'.sht-shard{position:absolute;overflow:hidden;will-change:transform}' +
'.sht-shard>*{position:absolute!important;margin:0!important;max-width:none!important}' +
'.sht-contained{position:relative;overflow:hidden;display:grid}' +
'.sht-contained>*{grid-area:1/1}' +
'.sht-contained>.sht-in{position:absolute;inset:0;z-index:2;pointer-events:none}';
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('sht-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('sht-pre');
setTimeout(unhide, 3000);
}
function rand(a, b) { return a + Math.random() * (b - a); }
// Triangles covering a w x h box: a jittered grid, each cell cut along a
// random diagonal. Edge points stay on the edges so nothing leaks.
function triangles(w, h, size) {
var cols = Math.max(3, Math.min(8, Math.round(w / size))), rows = Math.max(3, Math.min(6, Math.round(h / size)));
var cw = w / cols, ch = h / rows, pts = [], tris = [];
for (var r = 0; r <= rows; r++) {
pts.push([]);
for (var c = 0; c <= cols; c++) {
var x = c * cw, y = r * ch;
if (c > 0 && c < cols) x += rand(-0.35, 0.35) * cw;
if (r > 0 && r < rows) y += rand(-0.35, 0.35) * ch;
pts[r].push([x, y]);
}
}
for (r = 0; r < rows; r++) for (c = 0; c < cols; c++) {
var a = pts[r][c], b = pts[r][c + 1], d = pts[r + 1][c], e = pts[r + 1][c + 1];
if (Math.random() < 0.5) tris.push([a, b, e], [a, e, d]);
else tris.push([a, b, d], [b, e, d]);
}
return tris;
}
// Builds the shards in `layer`; fill(shard, left, top) puts the content in
// each. Returns a painter for time `ms` along the fall.
function shatter(layer, w, h, size, duration, fill) {
var ix = w * rand(0.3, 0.7), iy = h * rand(0.3, 0.6);
var far = Math.hypot(Math.max(ix, w - ix), Math.max(iy, h - iy));
var spread = duration * 0.35, fall = duration * 0.65, crack = 140;
var shards = triangles(w, h, size).map(function (t) {
var x0 = Math.min(t[0][0], t[1][0], t[2][0]), y0 = Math.min(t[0][1], t[1][1], t[2][1]);
var x1 = Math.max(t[0][0], t[1][0], t[2][0]), y1 = Math.max(t[0][1], t[1][1], t[2][1]);
var cx = (t[0][0] + t[1][0] + t[2][0]) / 3, cy = (t[0][1] + t[1][1] + t[2][1]) / 3;
var el = document.createElement('div');
el.className = 'sht-shard';
// One pixel of overlap hides hairline seams until the glass cracks.
var l = Math.floor(x0) - 1, tp = Math.floor(y0) - 1, sw = Math.ceil(x1) - l + 1, sh = Math.ceil(y1) - tp + 1;
el.style.cssText = 'left:' + l + 'px;top:' + tp + 'px;width:' + sw + 'px;height:' + sh + 'px;transform-origin:' + (cx - l) + 'px ' + (cy - tp) + 'px;' +
'clip-path:polygon(' + t.map(function (p) {
// Push each corner a little away from the centre: a slight overlap.
var dx = p[0] - cx, dy = p[1] - cy, k = 1 + 0.9 / Math.max(1, Math.hypot(dx, dy));
return (cx + dx * k - l).toFixed(1) + 'px ' + (cy + dy * k - tp).toFixed(1) + 'px';
}).join(',') + ')';
fill(el, l, tp);
layer.appendChild(el);
var dx = cx - ix, dy = cy - iy, d = Math.hypot(dx, dy) || 1;
return {
el: el,
delay: crack + spread * (d / far) + rand(0, 60),
push: 3 + 5 * (1 - d / far), ux: dx / d, uy: dy / d,
vx: dx / d * rand(20, 80), fallY: h * rand(1.05, 1.4),
rz: rand(-70, 70), rx: rand(-50, 50)
};
});
var total = crack + spread + fall + 60;
function paint(ms) {
for (var i = 0; i < shards.length; i++) {
var s = shards[i], p = Math.max(0, Math.min(1, (ms - s.delay) / fall));
var open = s.push * Math.min(1, ms / crack);
var x = s.ux * open + s.vx * p, y = s.uy * open + s.fallY * p * p;
s.el.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) perspective(600px) rotateX(' + (s.rx * p).toFixed(1) + 'deg) rotate(' + (s.rz * p).toFixed(1) + 'deg) scale(' + (1 - 0.15 * p).toFixed(3) + ')';
s.el.style.opacity = p > 0.75 ? ((1 - p) / 0.25).toFixed(3) : '';
}
}
return { paint: paint, total: total };
}
function play(total, paint, reverse, done) {
var s = { ms: 0 };
paint(reverse ? total : 0);
return anime.animate(s, {
ms: total, duration: total, ease: 'linear',
onUpdate: function () { paint(reverse ? total - s.ms : s.ms); },
onComplete: function () { paint(reverse ? 0 : total); 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 color = body.getAttribute('data-shatter-transition') || DEFAULTS.color;
var layer = null, anim, trip = 0;
// Leaving: the fall plays backwards, so the shards fly up and lock
// together. Arriving: the glass cracks and falls away.
function run(out, done) {
if (anim) anim.pause();
if (layer) layer.remove();
layer = document.createElement('div');
layer.className = 'sht-layer';
layer.setAttribute('aria-hidden', 'true');
var glass = shatter(layer, innerWidth, innerHeight, size, duration, function (el) {
el.style.background = 'linear-gradient(' + Math.round(rand(0, 360)) + 'deg,rgba(255,255,255,' + rand(0, 0.12).toFixed(2) + '),rgba(0,0,0,' + rand(0, 0.15).toFixed(2) + ')),' + color;
});
body.appendChild(layer);
anim = play(glass.total, glass.paint, out, function () {
if (!out) { layer.remove(); layer = 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 + 700);
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('sht-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var size = +el.dataset.size || DEFAULTS.size;
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 + 300,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
var from = slides[i], to = slides[(i + 1) % slides.length];
var layer = document.createElement('div');
layer.className = 'sht-in';
layer.setAttribute('aria-hidden', 'true');
// Each shard carries a copy of the old view, lined up with the original.
var glass = shatter(layer, w, h, Math.min(size, Math.max(70, Math.min(w, h) / 3)), duration, function (shard, l, t) {
var c = from.cloneNode(true);
c.removeAttribute('id');
c.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
c.style.left = -l + 'px';
c.style.top = -t + 'px';
c.style.width = w + 'px';
c.style.height = h + 'px';
c.style.visibility = 'visible';
shard.appendChild(c);
});
el.appendChild(layer);
to.style.visibility = 'visible';
from.style.visibility = 'hidden';
play(glass.total, glass.paint, false, function () { layer.remove(); });
i = (i + 1) % slides.length;
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-shatter-transition]').forEach(function (el) {
if (el._sht) return;
el._sht = 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-shatter-transition>
…
</body>