Glass Shatter
Click a card and it cracks like a pane of glass, a star of cracks racing out from where you hit it, then breaks into sharp shards that tumble and fall away. A moment later a fresh pane fades back in, or it’s gone for good.
How to use it
- Paste the snippet just before
</head>. - Add
data-glass-shatterto a card, image or button. It shatters from wherever it’s clicked. - The shards fall below the element, so give its container
overflow: hiddenif they shouldn’t cover what’s underneath.
Options
Add any of these attributes to the same element.
data-remove | Don’t come back: remove the element afterwards, e.g. a dismissed card. |
data-delay | Time before a fresh pane fades back in. Default 900 ms. |
data-pieces | Roughly how many shards. Default 40. |
data-loop | Shatter by itself every few seconds while in view. Handy for demos. |
The shards are real copies of the element, so text, images and gradients all break up. It looks most like glass on a see-through, frosted card. Keyboard clicks break it from the middle, and with reduced motion it just fades. From code, animacious.shatter(element, x, y) breaks it at a point.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Glass Shatter: click an element and it cracks like a pane of glass, with
// a star of cracks running out from the spot you hit, then breaks into
// sharp shards that tumble and fall away. A moment later a fresh pane fades
// back in, or it's gone for good.
// Usage: <div data-glass-shatter>…</div> (shatters on click)
// Optional: data-remove (don't come back: the element is removed),
// data-delay (ms before it comes back, default 900), data-pieces (roughly
// how many shards, default 40), data-loop (shatter by itself every few
// seconds while in view; good for demos).
// Script use: animacious.shatter(element, x, y) breaks it from code.
// Shards fall below the element, so give its container overflow: hidden
// if they shouldn't cover what's underneath.
var DEFAULTS = { delay: 900, pieces: 40, every: 3800 };
var css =
'[data-glass-shatter]{cursor:pointer}' +
'.gsh-layer{position:absolute;pointer-events:none;z-index:5;perspective:900px}' +
'.gsh-shard{position:absolute;inset:0;will-change:transform,opacity}' +
'.gsh-shard>.gsh-copy{position:absolute!important;left:0!important;top:0!important;margin:0!important;box-sizing:border-box;transform:none!important;opacity:1!important}' +
'.gsh-svg{position:absolute;left:0;top:0;overflow:visible}' +
'.gsh-edge{fill:rgba(255,255,255,.1);stroke:rgba(255,255,255,.9);stroke-width:1;stroke-linejoin:round;opacity:0}' +
'.gsh-crack{fill:none;stroke:#fff;stroke-width:1.2;stroke-linecap:round;filter:drop-shadow(0 0 1px rgba(0,0,0,.35))}' +
'.gsh-flash{position:absolute;width:70px;height:70px;margin:-35px 0 0 -35px;border-radius:50%;pointer-events:none;' +
'background:radial-gradient(circle,#fff 0,rgba(255,255,255,.75) 18%,rgba(255,255,255,0) 65%)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
// Clip a polygon to the box 0..w × 0..h (Sutherland–Hodgman).
function clipToBox(poly, w, h) {
var edges = [
function (p) { return p[0] >= 0; }, function (p) { return p[0] <= w; },
function (p) { return p[1] >= 0; }, function (p) { return p[1] <= h; }
];
var cuts = [
function (a, b) { var t = (0 - a[0]) / (b[0] - a[0]); return [0, a[1] + t * (b[1] - a[1])]; },
function (a, b) { var t = (w - a[0]) / (b[0] - a[0]); return [w, a[1] + t * (b[1] - a[1])]; },
function (a, b) { var t = (0 - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), 0]; },
function (a, b) { var t = (h - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), h]; }
];
var out = poly;
for (var e = 0; e < 4 && out.length; e++) {
var inp = out;
out = [];
for (var i = 0; i < inp.length; i++) {
var cur = inp[i], prev = inp[(i + inp.length - 1) % inp.length];
var ci = edges[e](cur), pi = edges[e](prev);
if (ci) {
if (!pi) out.push(cuts[e](prev, cur));
out.push(cur);
} else if (pi) out.push(cuts[e](prev, cur));
}
}
return out;
}
function area(poly) {
var a = 0;
for (var i = 0; i < poly.length; i++) {
var p = poly[i], q = poly[(i + 1) % poly.length];
a += p[0] * q[1] - q[0] * p[1];
}
return Math.abs(a / 2);
}
function shatter(el, ox, oy) {
if (el._gshBusy) return;
var d = el.dataset;
var remove = d.remove !== undefined && d.remove !== 'false';
var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
el._gshBusy = true;
anime.animate(el, {
opacity: remove ? 0 : [1, 0.2, 1], duration: remove ? 300 : 900,
onComplete: function () { el._gshBusy = false; if (remove) el.remove(); }
});
return;
}
var w = el.offsetWidth, h = el.offsetHeight;
if (!w || !h) return;
el._gshBusy = true;
var R = anime.utils.random;
ox = ox === undefined ? w / 2 : ox;
oy = oy === undefined ? h / 2 : oy;
// The crack pattern: rays from the impact, crossed by rings.
var pieces = +d.pieces || DEFAULTS.pieces;
var rays = Math.max(6, Math.min(16, Math.round(Math.sqrt(pieces) * 1.5)));
var rings = Math.max(2, Math.ceil(pieces / rays));
var far = 0;
[[0, 0], [w, 0], [0, h], [w, h]].forEach(function (c) { far = Math.max(far, Math.hypot(c[0] - ox, c[1] - oy)); });
var angles = [];
for (var a = 0; a < rays; a++) angles.push(((a + R(-0.3, 0.3, 2)) / rays) * Math.PI * 2);
var pts = []; // pts[ray][ring]
for (var i = 0; i < rays; i++) {
pts.push([[ox, oy]]);
for (var j = 1; j <= rings; j++) {
var r = j === rings ? far * 1.6 : far * Math.pow(j / rings, 1.35) * R(0.82, 1.15, 2);
var aa = angles[i] + R(-0.12, 0.12, 2);
pts[i].push([ox + Math.cos(aa) * r, oy + Math.sin(aa) * r]);
}
}
var layer = document.createElement('div');
layer.className = 'gsh-layer';
layer.setAttribute('aria-hidden', 'true');
layer.style.cssText = 'left:' + el.offsetLeft + 'px;top:' + el.offsetTop + 'px;width:' + w + 'px;height:' + h + 'px';
var copy = el.cloneNode(true);
copy.removeAttribute('data-glass-shatter');
copy.removeAttribute('id');
copy.querySelectorAll('[id]').forEach(function (n) { n.removeAttribute('id'); });
copy.classList.add('gsh-copy');
copy.style.width = w + 'px';
copy.style.height = h + 'px';
var shards = [];
function addShard(poly, ring) {
poly = clipToBox(poly, w, h);
if (poly.length < 3 || area(poly) < 6) return;
var cx = 0, cy = 0;
poly.forEach(function (p) { cx += p[0]; cy += p[1]; });
cx /= poly.length;
cy /= poly.length;
var s = document.createElement('div');
s.className = 'gsh-shard';
s.style.transformOrigin = cx + 'px ' + cy + 'px';
var c = copy.cloneNode(true);
var list = poly.map(function (p) { return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'; }).join(',');
c.style.clipPath = 'polygon(' + list + ')';
s.appendChild(c);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'gsh-svg');
svg.setAttribute('width', w);
svg.setAttribute('height', h);
var pg = document.createElementNS(NS, 'polygon');
pg.setAttribute('class', 'gsh-edge');
pg.setAttribute('points', poly.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' '));
svg.appendChild(pg);
s.appendChild(svg);
layer.appendChild(s);
shards.push({ el: s, edge: pg, cx: cx, cy: cy, ring: ring });
}
for (var si = 0; si < rays; si++) {
var n = (si + 1) % rays;
for (var sj = 0; sj < rings; sj++) {
var quad = [pts[si][sj], pts[n][sj], pts[n][sj + 1], pts[si][sj + 1]];
if (sj === 0) addShard([pts[si][0], pts[n][1], pts[si][1]], 0);
else if (Math.random() < 0.35) {
// Now and then split a piece in two along a diagonal.
addShard([quad[0], quad[1], quad[2]], sj);
addShard([quad[0], quad[2], quad[3]], sj);
} else addShard(quad, sj);
}
}
// The cracks, drawn on top of the still-whole pane.
var cracks = document.createElementNS(NS, 'svg');
cracks.setAttribute('class', 'gsh-svg');
cracks.setAttribute('width', w);
cracks.setAttribute('height', h);
cracks.style.overflow = 'hidden';
var paths = [];
function crack(list, ring) {
var p = document.createElementNS(NS, 'path');
p.setAttribute('class', 'gsh-crack');
p.setAttribute('pathLength', '1');
p.style.strokeDasharray = '1';
p.style.strokeDashoffset = '1';
p.setAttribute('d', 'M' + list.map(function (q) { return q[0].toFixed(1) + ' ' + q[1].toFixed(1); }).join('L'));
cracks.appendChild(p);
paths.push({ el: p, ring: ring });
}
for (var ci = 0; ci < rays; ci++) crack(pts[ci], 0);
for (var cj = 1; cj < rings; cj++) {
for (var ck = 0; ck < rays; ck++) {
if (Math.random() < 0.8) crack([pts[ck][cj], pts[(ck + 1) % rays][cj]], cj);
}
}
layer.appendChild(cracks);
var flash = document.createElement('span');
flash.className = 'gsh-flash';
flash.style.left = ox + 'px';
flash.style.top = oy + 'px';
layer.appendChild(flash);
el.parentNode.insertBefore(layer, el.nextSibling);
el.style.opacity = '0';
// 1. Impact: a flash, a jolt and the cracks racing outwards.
anime.animate(flash, { scale: [0.2, 1.6], opacity: [1, 0], duration: 420, ease: 'out(3)' });
anime.animate(layer, { x: [0, R(-3, 3), R(-2, 2), 0], y: [0, R(-2, 2), 0], duration: 260, ease: 'linear' });
paths.forEach(function (p) {
var k = { v: 1 };
anime.animate(k, {
v: 0, duration: p.ring ? 160 : 220, delay: p.ring ? 90 + p.ring * 45 : R(0, 40), ease: 'out(2)',
onUpdate: function () { p.el.style.strokeDashoffset = k.v; }
});
});
// 2. It gives way: the shards drop, those nearest the impact first.
var breakAt = 120 + rings * 45 + 260;
var longest = 0;
shards.forEach(function (s) {
var dx = s.cx - ox, dy = s.cy - oy, dist = Math.hypot(dx, dy) || 1;
var wait = breakAt + s.ring * 55 + R(0, 90);
var dur = R(750, 1100);
longest = Math.max(longest, wait + dur);
var push = R(6, 14) * (1 - Math.min(1, dist / far)) + 2;
anime.animate(s.edge, { opacity: 1, duration: 120, delay: breakAt - 40 });
anime.animate(s.el, {
x: [{ to: (dx / dist) * push, duration: 140, ease: 'out(2)' }, { to: (dx / dist) * R(14, 50), duration: dur - 140, ease: 'out(1.5)' }],
y: [{ to: (dy / dist) * push, duration: 140, ease: 'out(2)' }, { to: h + R(60, 220), duration: dur - 140, ease: 'in(2.2)' }],
rotate: { to: R(-55, 55), duration: dur, ease: 'in(1.5)' },
rotateX: { to: R(-70, 70), duration: dur, ease: 'in(1.5)' },
rotateY: { to: R(-50, 50), duration: dur, ease: 'in(1.5)' },
opacity: [{ to: 1, duration: dur * 0.65 }, { to: 0, duration: dur * 0.35 }],
delay: wait
});
});
anime.animate(cracks, { opacity: 0, duration: 120, delay: breakAt });
// 3. Clean up, and bring back a fresh pane.
setTimeout(function () {
layer.remove();
if (remove) { el.remove(); return; }
setTimeout(function () {
el.style.opacity = '';
anime.animate(el, {
opacity: [0, 1], scale: [0.94, 1], duration: 520, ease: 'out(3)',
onComplete: function () { el._gshBusy = false; }
});
}, delay);
}, longest + 60);
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('[data-glass-shatter]').forEach(function (el) {
if (el._gsh) return;
el._gsh = true;
el.addEventListener('click', function (e) {
var r = el.getBoundingClientRect();
// Keyboard clicks have no position: break from the middle.
var fromKey = e.detail === 0;
shatter(el, fromKey ? undefined : (e.clientX - r.left) * (el.offsetWidth / r.width), fromKey ? undefined : (e.clientY - r.top) * (el.offsetHeight / r.height));
});
if (el.dataset.loop === undefined || reduced) return;
var timer = null, first = null;
new IntersectionObserver(function (entries) {
clearInterval(timer);
clearTimeout(first);
if (!entries[0].isIntersecting) return;
var go = function () {
shatter(el, el.offsetWidth * anime.utils.random(0.25, 0.75, 2), el.offsetHeight * anime.utils.random(0.3, 0.7, 2));
};
first = setTimeout(go, 900);
timer = setInterval(go, DEFAULTS.every);
}, { threshold: 0.5 }).observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.shatter = function (el, x, y) { if (el) shatter(el, x, y); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="card" data-glass-shatter>…</div>
<!-- gone for good once it's broken: -->
<div class="card" data-glass-shatter data-remove>…</div>