Liquid Glass
A thick, rounded pane of glass sits over a picture and bends it. The middle magnifies a little, the curved rim pulls in the picture from round its edges with a hint of rainbow, and a bright highlight runs along the top of the rim. Drag it around and watch the picture warp.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-liquid-glassand give the wrapper a size. The image fills it, cropped to cover. - Use an image from your own site (or an inline SVG): the script has to read its pixels.
- Anything else you put in the wrapper, like a label or an icon, rides on the glass.
Options
Add any of these attributes to the same element.
data-width | Width of the pane. Default 220 px, shrunk to fit small boxes. |
data-height | Height of the pane. Default 120 px. |
data-radius | Corner radius. Default half the height, for a pill. |
data-rim | How wide the curved rim is. Default 22 px. |
data-strength | How hard the rim bends the picture. Default 1. |
data-autoplay | The pane drifts around by itself while nobody is dragging it. |
With reduced motion the pane stays where you drop it and doesn’t drift.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Liquid Glass: a thick, rounded pane of glass sits over an image and bends
// it. The middle magnifies a little, the curved rim pulls in the picture
// from round the edges with a hint of rainbow, and a bright highlight runs
// along the top of the rim. Drag the pane around, or let it drift by itself.
// Usage: <figure data-liquid-glass><img src="photo.jpg" alt="…"></figure>
// The image (an <img> on the same site, or an inline <svg>) fills the box,
// cropped to cover. Anything else inside the wrapper rides on the glass.
// Optional: data-width / data-height (size of the pane in px, default 220 by
// 120, shrunk to fit small boxes), data-radius (corner radius, default half
// the height for a pill), data-rim (how wide the curved rim is, px, default
// 22), data-strength (how hard the rim bends, default 1), data-autoplay (the
// pane drifts around while nobody is dragging it).
var DEFAULTS = { width: 220, height: 120, rim: 22, strength: 1, magnify: 0.9 };
var css =
'.lqg{position:relative;overflow:hidden;touch-action:pan-y}' +
'.lqg-src{opacity:0}' +
'.lqg-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}' +
'.lqg-pane{position:absolute;left:0;top:0;display:grid;place-items:center;cursor:grab;touch-action:none;color:#fff;text-shadow:0 1px 8px rgba(0,0,0,.35);user-select:none;-webkit-user-select:none}' +
'.lqg-pane:active{cursor:grabbing}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function loadSource(node, done) {
if (node.tagName.toLowerCase() === 'svg') {
var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
var k = 1200 / Math.max(vw, vh);
var clone = node.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', Math.round(vw * k));
clone.setAttribute('height', Math.round(vh * k));
var im = new Image();
im.onload = function () { done(im, im.width, im.height); };
im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
} else if (node.complete && node.naturalWidth) {
done(node, node.naturalWidth, node.naturalHeight);
} else {
node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
}
}
function cover(ctx, img, iw, ih, w, h) {
var s = Math.max(w / iw, h / ih);
ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
}
// Signed distance to a rounded rectangle centred on 0,0 (negative inside).
function sdf(x, y, hw, hh, r) {
var qx = Math.abs(x) - hw + r, qy = Math.abs(y) - hh + r;
var ox = Math.max(qx, 0), oy = Math.max(qy, 0);
return Math.sqrt(ox * ox + oy * oy) + Math.min(Math.max(qx, qy), 0) - r;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function setup(el, img, iw, ih) {
var d = el.dataset;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var strength = d.strength !== undefined ? +d.strength : DEFAULTS.strength;
var canvas = document.createElement('canvas');
canvas.className = 'lqg-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
// Everything else in the wrapper rides on the pane.
var pane = document.createElement('div');
pane.className = 'lqg-pane';
Array.prototype.slice.call(el.children).forEach(function (c) {
if (c !== canvas && !c.classList.contains('lqg-src')) pane.appendChild(c);
});
el.appendChild(pane);
var base = document.createElement('canvas');
var bctx = base.getContext('2d', { willReadFrequently: true });
var W = 0, H = 0, dpr = 1, src = null, out = null;
var pw = 0, ph = 0, map = null; // pane size in canvas pixels, and its refraction map
var cssW = 0, cssH = 0, radius = 0;
function build() {
var w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return false;
dpr = Math.min(2, window.devicePixelRatio || 1);
W = Math.round(w * dpr); H = Math.round(h * dpr);
canvas.width = base.width = W;
canvas.height = base.height = H;
cover(bctx, img, iw, ih, W, H);
try { src = bctx.getImageData(0, 0, W, H).data; }
catch (err) { canvas.remove(); el.querySelector('.lqg-src').classList.remove('lqg-src'); return false; }
cssW = Math.min(+d.width || DEFAULTS.width, w * 0.72);
cssH = Math.min(+d.height || DEFAULTS.height, h * 0.62, cssW);
radius = Math.min(d.radius !== undefined ? +d.radius : cssH / 2, cssH / 2, cssW / 2);
var rim = Math.min(+d.rim || DEFAULTS.rim, cssH * 0.4) * dpr;
pane.style.width = cssW + 'px';
pane.style.height = cssH + 'px';
pane.style.borderRadius = radius + 'px';
pw = Math.round(cssW * dpr); ph = Math.round(cssH * dpr);
out = ctx.createImageData(pw, ph);
// For every pixel in the pane: where to sample the picture (one offset per colour,
// for a touch of rainbow on the rim), a light term, and whether it is inside at all.
map = new Float32Array(pw * ph * 5);
var hw = pw / 2, hh = ph / 2, r = radius * dpr;
for (var y = 0; y < ph; y++) {
for (var x = 0; x < pw; x++) {
var px = x + 0.5 - hw, py = y + 0.5 - hh;
var dist = sdf(px, py, hw, hh, r);
var i = (y * pw + x) * 5;
if (dist > 0) { map[i + 4] = 0; continue; }
// Outward normal from the gradient of the distance field.
var nx = sdf(px + 1, py, hw, hh, r) - sdf(px - 1, py, hw, hh, r);
var ny = sdf(px, py + 1, hw, hh, r) - sdf(px, py - 1, hw, hh, r);
var nl = Math.sqrt(nx * nx + ny * ny) || 1;
nx /= nl; ny /= nl;
// How far into the rim: 1 at the very edge, 0 once past it. The glass surface is a
// quarter circle there, so the bend climbs steeply towards the edge.
var t = Math.max(0, 1 + dist / rim);
var bend = (1 - Math.sqrt(Math.max(0, 1 - t * t))) * rim * 1.6 * strength;
// Magnify the middle a little, then push the rim outwards to pull in what lies beyond it.
map[i] = px * DEFAULTS.magnify + nx * bend;
map[i + 1] = py * DEFAULTS.magnify + ny * bend;
map[i + 2] = bend * 0.045; // colour spread
// Light from the top left catches the rim; a soft inner glow everywhere.
var lit = Math.max(0, -nx * 0.55 - ny * 0.85) * Math.pow(t, 3) * 150;
var dark = Math.max(0, nx * 0.4 + ny * 0.9) * Math.pow(t, 4) * 50;
map[i + 3] = 10 + lit - dark;
// Soften the outline over a pixel.
map[i + 4] = Math.min(1, -dist);
}
}
return true;
}
// Pane centre in CSS pixels.
var pos = { x: 0, y: 0 };
function draw() {
if (!src) return;
ctx.drawImage(base, 0, 0);
var cx = pos.x * dpr, cy = pos.y * dpr;
var x0 = Math.round(cx - pw / 2), y0 = Math.round(cy - ph / 2);
// A soft shadow under the pane, drawn only outside it.
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, H);
roundRect(ctx, x0, y0, pw, ph, radius * dpr);
ctx.clip('evenodd');
ctx.shadowColor = 'rgba(10,10,30,.35)';
ctx.shadowBlur = 24 * dpr;
ctx.shadowOffsetY = 8 * dpr;
ctx.beginPath();
roundRect(ctx, x0, y0, pw, ph, radius * dpr);
ctx.fill();
ctx.restore();
var o = out.data, sx, sy, j, k, i;
for (var y = 0; y < ph; y++) {
for (var x = 0; x < pw; x++) {
i = (y * pw + x) * 5;
k = (y * pw + x) * 4;
var a = map[i + 4];
// Outside the rounded corners: leave it see-through.
if (a <= 0) { o[k + 3] = 0; continue; }
var spread = map[i + 2], light = map[i + 3];
for (var c = 0; c < 3; c++) {
var s = 1 + (c - 1) * spread;
sx = Math.round(cx + map[i] * s); sy = Math.round(cy + map[i + 1] * s);
sx = sx < 0 ? 0 : sx >= W ? W - 1 : sx;
sy = sy < 0 ? 0 : sy >= H ? H - 1 : sy;
j = (sy * W + sx) * 4 + c;
o[k + c] = src[j] * 1.04 + light;
}
o[k + 3] = Math.round(a * 255);
}
}
// putImageData replaces pixels outright, so draw through a scratch canvas to keep the
// corners (alpha 0) showing what is underneath.
sctx.putImageData(out, 0, 0);
ctx.drawImage(scratch, 0, 0, pw, ph, x0, y0, pw, ph);
pane.style.transform = 'translate(' + (pos.x - cssW / 2) + 'px,' + (pos.y - cssH / 2) + 'px)';
}
var scratch = document.createElement('canvas');
var sctx = scratch.getContext('2d');
function sizeScratch() { scratch.width = pw; scratch.height = ph; }
if (!build()) return;
sizeScratch();
pos.x = el.clientWidth / 2; pos.y = el.clientHeight / 2;
draw();
function clamp() {
var w = el.clientWidth, h = el.clientHeight;
pos.x = Math.max(cssW / 2, Math.min(w - cssW / 2, pos.x));
pos.y = Math.max(cssH / 2, Math.min(h - cssH / 2, pos.y));
}
if (window.ResizeObserver) {
var lw = el.clientWidth, lh = el.clientHeight;
new ResizeObserver(function () {
if (el.clientWidth === lw && el.clientHeight === lh) return;
lw = el.clientWidth; lh = el.clientHeight;
if (build()) { sizeScratch(); clamp(); draw(); }
}).observe(el);
}
// Dragging: the pane follows the finger or mouse, keeping where you grabbed it.
var drag = null, lastUser = -1e9, target = { x: pos.x, y: pos.y };
pane.addEventListener('pointerdown', function (e) {
drag = { dx: pos.x - e.clientX, dy: pos.y - e.clientY };
pane.setPointerCapture(e.pointerId);
e.preventDefault();
});
pane.addEventListener('pointermove', function (e) {
if (!drag) return;
target.x = e.clientX + drag.dx;
target.y = e.clientY + drag.dy;
lastUser = performance.now();
if (!timer) { pos.x = target.x; pos.y = target.y; clamp(); draw(); }
});
function end() { drag = null; lastUser = performance.now(); }
pane.addEventListener('pointerup', end);
pane.addEventListener('pointercancel', end);
var timer = null;
if (reduced()) return;
var start = performance.now();
timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var w = el.clientWidth, h = el.clientHeight;
if (autoplay && !drag && now - lastUser > 2000) {
// A lazy figure-of-eight over the picture.
var a = (now - start) / 1000;
target.x = w / 2 + Math.sin(a * 0.5) * Math.max(0, w / 2 - cssW / 2 - 6);
target.y = h / 2 + Math.sin(a * 0.8) * Math.max(0, h / 2 - cssH / 2 - 6) * 0.85;
}
var nx = pos.x + (target.x - pos.x) * (drag ? 0.5 : 0.08);
var ny = pos.y + (target.y - pos.y) * (drag ? 0.5 : 0.08);
if (Math.abs(nx - pos.x) < 0.05 && Math.abs(ny - pos.y) < 0.05) return;
pos.x = nx; pos.y = ny;
clamp();
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play();
else timer.pause();
}, { threshold: 0.1 }).observe(el);
}
function init() {
document.querySelectorAll('[data-liquid-glass]').forEach(function (el) {
if (el._lqg) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._lqg = true;
el.classList.add('lqg');
src.classList.add('lqg-src');
loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-liquid-glass data-autoplay style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
<span>Drag me</span>
</figure>