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
- Paste the snippet just before
</head>. - Add
data-shockwave-ringto a section, card or image. Each click or tap sends a ring out from that spot. - Add
data-autoplayto 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-autoplay | Send one out from a random spot every few seconds. It pauses for a while after a real click. |
data-duration | How long the ring takes to spread. Default 1100 ms. |
data-size | How far it spreads, as a share of the element’s longer side. Default 0.8. |
data-strength | How much it bends what’s beneath, in px. Default 36. |
data-color | Colour 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.
<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><section data-shockwave-ring data-autoplay>
<h1>Impact</h1>
</section>