← Particles

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

  1. Paste the snippet just before </head>.
  2. Add data-glass-shatter to a card, image or button. It shatters from wherever it’s clicked.
  3. The shards fall below the element, so give its container overflow: hidden if they shouldn’t cover what’s underneath.

Options

Add any of these attributes to the same element.

data-removeDon’t come back: remove the element afterwards, e.g. a dismissed card.
data-delayTime before a fresh pane fades back in. Default 900 ms.
data-piecesRoughly how many shards. Default 40.
data-loopShatter 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.

1 · Snippet
<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>
2 · Your HTML
<div class="card" data-glass-shatter>…</div>

<!-- gone for good once it's broken: -->
<div class="card" data-glass-shatter data-remove>…</div>