← Particles

Shockwave Ring

Click or tap and a ring-shaped shockwave races out from that spot. Everything underneath bends as it passes, like looking through a lens, with a faint bright rim on the wave, then it dies away and leaves the page as it was.

ImpactClick or tap anywhere

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-shockwave-ring to a section, card or image. Each click or tap sends a ring out from that spot.
  3. Add data-autoplay to have it send one out by itself every few seconds while it’s in view.

Options

Add any of these attributes to the same element.

data-autoplaySend one out from a random spot every few seconds. It pauses for a while after a real click.
data-durationHow long the ring takes to spread. Default 1100 ms.
data-sizeHow far it spreads, as a share of the element’s longer side. Default 0.8.
data-strengthHow much it bends what’s beneath, in px. Default 36.
data-colorColour of the rim. Default white.

The bending is an SVG displacement filter, so it works on text, images and backgrounds alike, and the filter is only on while a ring is spreading. Busy content, like a grid or a photo, shows it off best. With reduced motion nothing happens. From code, animacious.shockwave(element, x, y) sends one out from a point.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Shockwave Ring: click or tap and a ring-shaped shockwave races out from
    // that spot, bending everything beneath it like a lens as it passes (text,
    // images and backgrounds all warp), with a faint bright rim, then dies away.
    // The warp is an SVG displacement filter, so it works on any content.
    // Usage: <section data-shockwave-ring>…</section>
    // Optional: data-autoplay (send one out from a random spot every few
    // seconds while in view, pausing for a while after a real click),
    // data-duration (ms for the ring to spread, default 1100), data-size (how
    // far it spreads, as a share of the element's longer side, default 0.8),
    // data-strength (how much it warps, in px, default 36), data-color (rim
    // colour, default white).
    // Script use: animacious.shockwave(element, x, y) sends one out from a
    // point (px from the element's top-left; the centre if left out).
    var DEFAULTS = { duration: 1100, size: 0.8, strength: 36, color: '#ffffff' };
    var MAP_SIZE = 256;
    var mapUrl = null;
    var count = 0;

    var css =
      '[data-shockwave-ring]{position:relative;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
      '.swv-svg{position:absolute;width:0;height:0;overflow:hidden}' +
      '.swv-rim{position:absolute;left:0;top:0;width:0;height:0;border-radius:50%;pointer-events:none;opacity:0;' +
      'border:1.5px solid var(--swv-c);box-shadow:0 0 14px 1px var(--swv-c),inset 0 0 14px 1px var(--swv-c)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // The displacement map: a ring that pushes pixels outwards on its outer
    // half and inwards on its inner half, neutral grey everywhere else.
    function makeMap() {
      if (mapUrl) return mapUrl;
      var c = document.createElement('canvas');
      c.width = c.height = MAP_SIZE;
      var x = c.getContext('2d');
      var img = x.createImageData(MAP_SIZE, MAP_SIZE);
      var half = MAP_SIZE / 2;
      for (var j = 0; j < MAP_SIZE; j++) {
        for (var i = 0; i < MAP_SIZE; i++) {
          var dx = (i + 0.5 - half) / half, dy = (j + 0.5 - half) / half;
          var r = Math.sqrt(dx * dx + dy * dy) || 1e-6;
          // Derivative of a bell centred on r = .82: a lens-like bulge.
          var u = (r - 0.82) / 0.09;
          var f = r > 1 ? 0 : -u * Math.exp(-u * u) * 1.65;
          var k = (j * MAP_SIZE + i) * 4;
          img.data[k] = 128 + Math.round((dx / r) * f * 127);
          img.data[k + 1] = 128 + Math.round((dy / r) * f * 127);
          img.data[k + 2] = 128;
          img.data[k + 3] = 255;
        }
      }
      x.putImageData(img, 0, 0);
      mapUrl = c.toDataURL();
      return mapUrl;
    }

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function build(el) {
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var sizeShare = +d.size || DEFAULTS.size;
      var strength = d.strength !== undefined ? +d.strength : DEFAULTS.strength;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      el.style.setProperty('--swv-c', d.color || DEFAULTS.color);
      var id = 'swv-filter-' + (++count);
      var NS = 'http://www.w3.org/2000/svg';
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('class', 'swv-svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML =
        '<filter id="' + id + '" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">' +
        '<feFlood flood-color="#808080" result="flat"/>' +
        '<feImage preserveAspectRatio="none" result="ring"/>' +
        '<feComposite in="ring" in2="flat" operator="over" result="map"/>' +
        '<feDisplacementMap in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G"/>' +
        '</filter>';
      document.body.appendChild(svg);
      var feImage = svg.querySelector('feImage');
      var disp = svg.querySelector('feDisplacementMap');
      feImage.setAttribute('href', makeMap());
      feImage.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', mapUrl);
      var rim = document.createElement('span');
      rim.className = 'swv-rim';
      rim.setAttribute('aria-hidden', 'true');
      el.appendChild(rim);
      var anim = null, quietUntil = 0;

      function fire(x, y) {
        var w = el.offsetWidth, h = el.offsetHeight;
        if (x === undefined) { x = w / 2; y = h / 2; }
        var max = Math.max(w, h) * sizeShare;
        var s = { r: 0, k: 1 };
        if (anim) anim.revert();
        el.style.filter = 'url(#' + id + ')';
        anim = anime.animate(s, {
          r: max,
          k: 0,
          duration: duration,
          ease: 'out(2)',
          onUpdate: function () {
            // The map's ring sits at .82 of its radius; size it so that's s.r.
            var R = Math.max(1, s.r / 0.82);
            feImage.setAttribute('x', (x - R).toFixed(1));
            feImage.setAttribute('y', (y - R).toFixed(1));
            feImage.setAttribute('width', (R * 2).toFixed(1));
            feImage.setAttribute('height', (R * 2).toFixed(1));
            // Strongest just after it starts, fading as it spreads.
            var grow = Math.min(1, s.r / 40);
            disp.setAttribute('scale', (strength * s.k * grow).toFixed(2));
            // The rim rides on the wave, brightest early on.
            rim.style.left = (x - s.r) + 'px';
            rim.style.top = (y - s.r) + 'px';
            rim.style.width = rim.style.height = (s.r * 2) + 'px';
            rim.style.opacity = (Math.min(1, s.r / 30) * s.k * 0.85).toFixed(3);
          },
          onComplete: function () { el.style.filter = ''; disp.setAttribute('scale', '0'); rim.style.opacity = 0; }
        });
      }
      el._swvFire = fire;

      if (reduced()) return;

      el.addEventListener('pointerdown', function (e) {
        var r = el.getBoundingClientRect();
        quietUntil = performance.now() + 5000;
        fire((e.clientX - r.left) * (el.offsetWidth / r.width), (e.clientY - r.top) * (el.offsetHeight / r.height));
      });

      if (autoplay) {
        var timer = null;
        var tick = function () {
          if (performance.now() >= quietUntil) {
            var w = el.offsetWidth, h = el.offsetHeight;
            fire(w * (0.25 + Math.random() * 0.5), h * (0.3 + Math.random() * 0.4));
          }
        };
        new IntersectionObserver(function (entries) {
          if (entries[0].isIntersecting) {
            if (!timer) { setTimeout(tick, 400); timer = setInterval(tick, duration + 1300); }
          } else if (timer) { clearInterval(timer); timer = null; }
        }).observe(el);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.shockwave = function (el, x, y) { if (el && el._swvFire && !reduced()) el._swvFire(x, y); };

    function init() {
      document.querySelectorAll('[data-shockwave-ring]').forEach(function (el) {
        if (el._swv) return;
        el._swv = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-shockwave-ring data-autoplay>
  <h1>Impact</h1>
</section>