← Filters & retro

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.

Drag me

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-liquid-glass and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels.
  4. 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-widthWidth of the pane. Default 220 px, shrunk to fit small boxes.
data-heightHeight of the pane. Default 120 px.
data-radiusCorner radius. Default half the height, for a pill.
data-rimHow wide the curved rim is. Default 22 px.
data-strengthHow hard the rim bends the picture. Default 1.
data-autoplayThe pane drifts around by itself while nobody is dragging it.

With reduced motion the pane stays where you drop it and doesn’t drift.

1 · Snippet
<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>
2 · Your HTML
<figure data-liquid-glass data-autoplay style="height:400px">
  <img src="photo.jpg" alt="A mountain lake">
  <span>Drag me</span>
</figure>