Liquid Distortion Hover
Move over a picture and it ripples and warps under the pointer as if you were looking at it through moving water. Move faster and the warp swells; stop and it settles to a gentle shimmer, then smooths away when you leave.
data-fullHow to use it
- Paste the snippet just before
</head>. - Add
data-liquid-distortion-hoverto the frame around a picture. The picture should be its first child and fill it.
Options
Add any of these attributes to the same element.
data-strength | How far the warp pushes the picture about, in px. Default 26. |
data-radius | Size of the rippling patch round the pointer, in px. Default 110. |
data-full | Warp the whole picture instead of a patch round the pointer. |
data-scale | Size of the ripples: higher makes them finer. Default 1. |
data-autoplay | A pretend pointer drifts across while a real one isn’t over it. Handy for demos and touch screens. |
It uses an SVG filter on a copy of the picture laid over it, so your image is left alone. On touch screens the ripple follows your finger for a moment. With reduced motion turned on, the picture stays still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Liquid Distortion Hover: the picture ripples and warps under the pointer
// as if seen through moving water. The warp swells as the pointer moves
// faster and settles to a gentle shimmer when it stops.
// Usage: <figure data-liquid-distortion-hover><img src="…" alt="…"></figure>
// The picture should be the frame's first child and fill it.
// Optional: data-strength (how far pixels are pushed, px, default 26),
// data-radius (size of the rippling patch, px, default 110), data-full (warp
// the whole picture instead of a patch round the pointer), data-scale (size
// of the ripples: higher is finer, default 1), data-autoplay (a pretend
// pointer drifts across while a real one isn't over it; handy for demos and
// touch screens).
var DEFAULTS = { strength: 26, radius: 110, scale: 1 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.ldh{position:relative;overflow:hidden;isolation:isolate}' +
'.ldh-fx{position:absolute;inset:0;z-index:1;pointer-events:none;visibility:hidden}' +
'.ldh-fx>*{display:block;width:100%;height:100%;max-width:none;margin:0}' +
'.ldh-defs{position:absolute;width:0;height:0;overflow:hidden}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
var uid = 0;
function setup(el) {
var d = el.dataset;
var strength = num(d.strength, DEFAULTS.strength);
var radius = num(d.radius, DEFAULTS.radius);
var fine = num(d.scale, DEFAULTS.scale);
var full = d.full !== undefined && d.full !== 'false';
var pic = el.firstElementChild;
if (!pic) return;
el.classList.add('ldh');
// The warped copy sits over the picture, masked to a soft circle round
// the pointer, so the picture itself is never touched.
var fx = document.createElement('div');
fx.className = 'ldh-fx';
fx.setAttribute('aria-hidden', 'true');
var copy = pic.cloneNode(true);
copy.removeAttribute('id');
if (copy.tagName === 'IMG') copy.style.objectFit = getComputedStyle(pic).objectFit === 'fill' ? 'cover' : getComputedStyle(pic).objectFit;
fx.appendChild(copy);
var id = 'ldh-f' + (++uid);
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'ldh-defs');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML =
'<filter id="' + id + '" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">' +
'<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="' + (uid * 7 % 97) + '" result="n"/>' +
'<feDisplacementMap in="SourceGraphic" in2="n" scale="0" xChannelSelector="R" yChannelSelector="G"/>' +
'</filter>';
el.appendChild(svg);
el.appendChild(fx);
var filter = svg.firstChild, turb = filter.firstChild, disp = turb.nextSibling;
fx.style.filter = 'url(#' + id + ')';
var s = { x: 0, y: 0, tx: 0, ty: 0, k: 0, boost: 0, t: Math.random() * 10 };
var hover = false, real = false, hold = 0, visible = false, raf = 0, w = 0, h = 0;
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
function size() { w = el.clientWidth; h = el.clientHeight; }
size();
new ResizeObserver(size).observe(el);
function aim(cx, cy) {
var r = el.getBoundingClientRect();
var x = cx - r.left, y = cy - r.top;
if (!hover && s.k < 0.05) { s.x = x; s.y = y; }
s.tx = x; s.ty = y;
}
function frame() {
raf = 0;
if (!visible) return;
var touchHeld = Date.now() < hold;
// Pretend pointer: a slow loop across the picture.
if (auto && !real && !touchHeld) {
s.tx = w * (0.5 + 0.34 * Math.sin(s.t * 0.55));
s.ty = h * (0.5 + 0.3 * Math.sin(s.t * 0.9 + 1));
if (s.k < 0.05) { s.x = s.tx; s.y = s.ty; }
}
var on = hover || touchHeld || (auto && !real);
var px = s.x, py = s.y;
s.x += (s.tx - s.x) * 0.16;
s.y += (s.ty - s.y) * 0.16;
var speed = Math.hypot(s.x - px, s.y - py);
s.boost += (Math.min(1, speed / 18) - s.boost) * 0.08;
s.k += ((on ? 1 : 0) - s.k) * (on ? 0.07 : 0.05);
s.t += 0.016;
var f = 0.011 * fine;
turb.setAttribute('baseFrequency', (f + 0.003 * Math.sin(s.t * 1.3)).toFixed(5) + ' ' + (f * 1.7 + 0.004 * Math.cos(s.t)).toFixed(5));
disp.setAttribute('scale', (strength * s.k * (0.55 + 0.9 * s.boost)).toFixed(2));
if (full) {
filter.setAttribute('x', 0); filter.setAttribute('y', 0);
filter.setAttribute('width', w); filter.setAttribute('height', h);
} else {
// Only work out the patch round the pointer: much cheaper.
var r = radius * (0.85 + 0.35 * s.boost);
filter.setAttribute('x', (s.x - r - 10).toFixed(1)); filter.setAttribute('y', (s.y - r - 10).toFixed(1));
filter.setAttribute('width', (r * 2 + 20).toFixed(1)); filter.setAttribute('height', (r * 2 + 20).toFixed(1));
var m = 'radial-gradient(circle at ' + s.x.toFixed(1) + 'px ' + s.y.toFixed(1) + 'px,#000 ' + (r * 0.35).toFixed(1) + 'px,transparent ' + r.toFixed(1) + 'px)';
fx.style.webkitMaskImage = fx.style.maskImage = m;
}
fx.style.visibility = s.k > 0.01 ? 'visible' : 'hidden';
if (on || s.k > 0.01) raf = requestAnimationFrame(frame);
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
real = hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 3000;
else real = hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
real = hover = false;
wake();
});
// Touch: the ripple jumps to the finger and lingers a moment.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 3000;
s.k = Math.min(s.k, 0.04);
aim(e.clientX, e.clientY);
s.boost = 1;
wake();
});
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
}).observe(el);
}
function init() {
if (reduced()) return;
document.querySelectorAll('[data-liquid-distortion-hover]').forEach(function (el) {
if (el._ldh) return;
el._ldh = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-liquid-distortion-hover>
<img src="lake.jpg" alt="A mountain lake">
</figure>
<!-- the whole picture, a little calmer: -->
<figure data-liquid-distortion-hover data-full data-strength="18">
<img src="aurora.jpg" alt="Northern lights">
</figure>