Live demo, running in your browser

Liquid lens

A giant black FLUID headline sits on a pale page while a glassy metaball lens, driven by spring physics toward the pointer, sheds droplets and magnifies a drifting rainbow-gradient copy of the word through wobbling strips, drifting on its own when idle.

Code: MIT license.

<!doctype html>
<html lang="en">
  <head
    ><script>
      /* 6to9 pattern harness */ (function () {
        var parentPaused = false,
          hidden = document.hidden,
          p = (window.__pattern = { paused: false, reducedMotion: false, maxDpr: 2 });
        function sync() {
          p.paused = parentPaused || hidden;
        }
        sync();
        try {
          p.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
        } catch (e) {}
        document.addEventListener("visibilitychange", function () {
          hidden = document.hidden;
          sync();
        });
        addEventListener("message", function (e) {
          var d = e.data || {};
          if (d.pattern === "pause") {
            parentPaused = true;
            sync();
          }
          if (d.pattern === "play") {
            parentPaused = false;
            sync();
          }
        });
      })();
    </script>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Fluid · liquid lens hero</title>
    <style>
      :root {
        --ink: #0b0b0f;
        --page: #e3e4e8;
      }
      * {
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        height: 100%;
        background: var(--page);
      }
      body {
        overflow: hidden;
        font-family:
          system-ui,
          -apple-system,
          "Segoe UI",
          Roboto,
          Helvetica,
          Arial,
          sans-serif;
        color: var(--ink);
      }
      .hero {
        position: relative;
        width: 100vw;
        height: 100vh;
        height: 100svh;
        overflow: hidden;
        touch-action: none;
      }
      #stage {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        display: block;
        z-index: 1;
      }
      .headline {
        position: absolute;
        width: 1px;
        height: 1px;
        margin: -1px;
        overflow: hidden;
        clip: rect(0 0 0 0);
        white-space: nowrap;
      }
      .top {
        position: absolute;
        left: 28px;
        right: 28px;
        top: 24px;
        z-index: 2;
        display: flex;
        justify-content: space-between;
        align-items: center;
        font-size: 13px;
        font-weight: 600;
        letter-spacing: 0.02em;
        pointer-events: none;
      }
      .brand {
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .dot {
        width: 14px;
        height: 14px;
        border-radius: 50%;
        background: linear-gradient(135deg, #ff2e88, #7b3cff 55%, #12b5ff);
      }
      .hint {
        opacity: 0.55;
      }
      .copy {
        position: absolute;
        left: 0;
        right: 0;
        bottom: max(6vh, 28px);
        z-index: 2;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 22px;
        padding: 0 24px;
        text-align: center;
      }
      .copy p {
        margin: 0;
        max-width: 38ch;
        font-size: 18px;
        line-height: 1.45;
        color: #2b2d35;
      }
      .pill {
        display: inline-flex;
        align-items: center;
        gap: 12px;
        padding: 10px 12px 10px 26px;
        border: 0;
        border-radius: 999px;
        background: var(--ink);
        color: #fff;
        font: inherit;
        font-size: 16px;
        font-weight: 700;
        cursor: pointer;
        transition:
          transform 0.25s ease,
          box-shadow 0.25s ease;
        box-shadow: 0 8px 22px rgba(20, 20, 40, 0.22);
      }
      .pill:hover {
        transform: translateY(-2px) scale(1.03);
        box-shadow: 0 14px 28px rgba(20, 20, 40, 0.26);
      }
      .pill:focus-visible {
        outline: 3px solid #7b3cff;
        outline-offset: 3px;
      }
      .pill .arrow {
        display: grid;
        place-items: center;
        width: 34px;
        height: 34px;
        border-radius: 50%;
        background: linear-gradient(135deg, #ff2e88, #7b3cff 55%, #12b5ff);
      }
      .fallback #stage {
        display: none;
      }
      .fallback .headline {
        left: 0;
        right: 0;
        top: 36%;
        width: auto;
        height: auto;
        margin: 0;
        overflow: visible;
        clip: auto;
        white-space: normal;
        transform: translateY(-50%);
        text-align: center;
        font-size: 27vw;
        font-weight: 900;
        letter-spacing: -0.04em;
        line-height: 0.85;
        background: linear-gradient(90deg, #ff2e88, #ff7a18, #7b3cff, #12b5ff, #ff2e88);
        background-size: 200% 100%;
        -webkit-background-clip: text;
        background-clip: text;
        color: transparent;
        animation: drift 6s linear infinite;
      }
      @keyframes drift {
        to {
          background-position: 200% 0;
        }
      }
      @media (max-width: 600px) {
        .top {
          left: 20px;
          right: 20px;
        }
        .copy p {
          font-size: 16px;
        }
      }
    </style>
  </head>
  <body>
    <main class="hero">
      <canvas id="stage" aria-hidden="true"></canvas>
      <h1 class="headline">FLUID</h1>
      <div class="top">
        <span class="brand"><span class="dot"></span>dropkit</span>
        <span class="hint">move to bend the word</span>
      </div>
      <div class="copy">
        <p
          >A pointer that pours, stretches and snaps back. Playful tools for interfaces, tuned to
          the pixel.</p
        >
        <button class="pill" type="button"
          >Try the lens
          <span class="arrow"
            ><svg
              width="16"
              height="16"
              viewBox="0 0 16 16"
              fill="none"
              stroke="#fff"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
            >
              <path d="M3 8h10M9 4l4 4-4 4" /></svg></span
        ></button>
      </div>
    </main>
    <script>
      (function () {
        var FALLBACK_PATTERN = { paused: false, reducedMotion: false, maxDpr: 2 };
        function pat() {
          return window.__pattern || FALLBACK_PATTERN;
        }
        var root = document.documentElement;
        var canvas = document.getElementById("stage");
        var ctx = null;
        try {
          ctx = canvas.getContext("2d");
        } catch (err) {
          ctx = null;
        }
        if (!ctx) {
          root.classList.add("fallback");
          return;
        }

        var WORD = "FLUID";
        var FONT = "'Arial Black', 'Helvetica Neue', Helvetica, Arial, sans-serif";
        var INK = "#0b0b0f";
        var S = 3;
        var KR = 1.8;
        var INV_T = 1 / 0.3305;
        var LX = -0.6;
        var LY = -0.8;
        var MAG = 1.14;
        var STRIP = 4;
        var PALETTE = ["#ff2e88", "#ff7a18", "#7b3cff", "#12b5ff", "#ff2e88"];

        var textCanvas = document.createElement("canvas");
        var tctx = textCanvas.getContext("2d");
        var gtCanvas = document.createElement("canvas");
        var gtx = gtCanvas.getContext("2d");
        var lensCanvas = document.createElement("canvas");
        var lx = lensCanvas.getContext("2d");
        var maskCanvas = document.createElement("canvas");
        var maskCtx = maskCanvas.getContext("2d");
        var rimCanvas = document.createElement("canvas");
        var rimCtx = rimCanvas.getContext("2d");

        var W = 0,
          H = 0,
          dpr = 1,
          cw = 0,
          ch = 0,
          lw = 0,
          lh = 0;
        var size = 100,
          ascent = 70,
          headY = 0,
          baseY = 0,
          gradW = 600,
          grad = null;
        var bandCss = { y: 0, h: 0 };
        var band = { y: 0, h: 0 };
        var field = null,
          maskData = null,
          rimData = null;

        var time = 0;
        var R = 100;
        var Rm = 100;
        var pulse = 0;
        var m = { x: 0, y: 0, vx: 0, vy: 0 };
        var droplets = [];
        var blobs = [];
        var spawnAcc = 0;
        var px = 0,
          py = 0;
        var lastMoveT = -10;
        var idleW = 1;
        var dirty = true;
        var stillReady = false;

        function clamp(v, a, b) {
          return v < a ? a : v > b ? b : v;
        }

        function smooth(a, b, v) {
          var t = clamp((v - a) / (b - a), 0, 1);
          return t * t * (3 - 2 * t);
        }

        function layout() {
          ctx.font = "900 100px " + FONT;
          if ("letterSpacing" in ctx) {
            ctx.letterSpacing = "-4px";
          }
          var meas = ctx.measureText(WORD);
          var w100 = meas.width || 300;
          var a100 = meas.actualBoundingBoxAscent || 72;
          if ("letterSpacing" in ctx) {
            ctx.letterSpacing = "0px";
          }
          size = Math.min(((W * 0.94) / w100) * 100, ((H * 0.46) / a100) * 100);
          ascent = (size * a100) / 100;
          headY = H * (W < 600 ? 0.34 : 0.4);
          baseY = headY + ascent / 2;
          var top = Math.max(0, headY - ascent / 2 - ascent * 0.3);
          var bottom = Math.min(H - 1, headY + ascent / 2 + ascent * 0.3);
          bandCss.y = top;
          bandCss.h = bottom - top;
          band.y = Math.floor(top * dpr);
          band.h = Math.min(Math.ceil(bandCss.h * dpr), ch - band.y);
          R = clamp(Math.min(W, H) * 0.19, 64, 150);
          gradW = Math.max(W * 0.8, 500);
        }

        function drawHeadline() {
          tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          tctx.clearRect(0, 0, W, H);
          tctx.font = "900 " + size + "px " + FONT;
          if ("letterSpacing" in tctx) {
            tctx.letterSpacing = -0.04 * size + "px";
          }
          tctx.textAlign = "center";
          tctx.textBaseline = "alphabetic";
          tctx.fillStyle = INK;
          tctx.fillText(WORD, W / 2, baseY);
        }

        function resize() {
          W = window.innerWidth;
          H = window.innerHeight;
          dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, pat().maxDpr || 2));
          cw = Math.round(W * dpr);
          ch = Math.round(H * dpr);
          canvas.width = cw;
          canvas.height = ch;
          textCanvas.width = cw;
          textCanvas.height = ch;
          gtCanvas.width = cw;
          gtCanvas.height = ch;
          lensCanvas.width = cw;
          lensCanvas.height = ch;
          lw = Math.ceil(W / S);
          lh = Math.ceil(H / S);
          maskCanvas.width = lw;
          maskCanvas.height = lh;
          rimCanvas.width = lw;
          rimCanvas.height = lh;
          field = new Float32Array(lw * lh);
          maskData = maskCtx.createImageData(lw, lh);
          rimData = rimCtx.createImageData(lw, lh);
          layout();
          drawHeadline();
          gtx.setTransform(dpr, 0, 0, dpr, 0, 0);
          grad = gtx.createLinearGradient(0, 0, gradW, 0);
          for (var i = 0; i < PALETTE.length; i++) {
            grad.addColorStop(i / (PALETTE.length - 1), PALETTE[i]);
          }
          if (!stillReady) {
            m.x = W * 0.3;
            m.y = headY;
            px = W / 2;
            py = headY;
            stillReady = true;
          }
          dirty = true;
        }

        function onMove(e) {
          px = e.clientX;
          py = e.clientY;
          lastMoveT = time;
        }

        function spawnDroplet(speed) {
          var dirx = speed > 1 ? m.vx / speed : 1;
          var diry = speed > 1 ? m.vy / speed : 0;
          var perp = Math.random() - 0.5;
          droplets.push({
            x: m.x - dirx * R * 0.8 - diry * perp * R * 1.2,
            y: m.y - diry * R * 0.8 + dirx * perp * R * 1.2,
            vx: m.vx * 0.5 - diry * perp * 260,
            vy: m.vy * 0.5 + dirx * perp * 260,
            r: R * (0.13 + Math.random() * 0.17),
            age: 0,
          });
        }

        function onDown(e) {
          px = e.clientX;
          py = e.clientY;
          lastMoveT = time;
          for (var i = 0; i < 5 && droplets.length < 14; i++) {
            spawnDroplet(0);
          }
        }

        function step(dt) {
          time += dt;
          var idle = time - lastMoveT > 3;
          idleW += ((idle ? 1 : 0) - idleW) * Math.min(1, dt * (idle ? 0.8 : 6));
          var spread = W < 600 ? 0.36 : 0.3;
          var wx = W / 2 + Math.sin(time * 0.42) * W * spread;
          var wy = headY + Math.sin(time * 0.83 + 0.6) * ascent * 0.28;
          var tx = px + (wx - px) * idleW + Math.sin(time * 1.3) * 5;
          var ty = py + (wy - py) * idleW + Math.cos(time * 1.1) * 5;
          var ax = (tx - m.x) * 70 - m.vx * 12;
          var ay = (ty - m.y) * 70 - m.vy * 12;
          m.vx += ax * dt;
          m.vy += ay * dt;
          m.x += m.vx * dt;
          m.y += m.vy * dt;
          var speed = Math.sqrt(m.vx * m.vx + m.vy * m.vy);
          pulse *= Math.exp(-dt * 5);
          Rm = R * (1 + 0.04 * Math.sin(time * 2.1) + pulse);
          if (speed > 180 && droplets.length < 14) {
            spawnAcc += (dt * (speed - 150)) / 60;
            while (spawnAcc >= 1 && droplets.length < 14) {
              spawnAcc -= 1;
              spawnDroplet(speed);
            }
          } else {
            spawnAcc = 0;
          }
          for (var i = droplets.length - 1; i >= 0; i--) {
            var d = droplets[i];
            d.age += dt;
            var tox = m.x - d.x;
            var toy = m.y - d.y;
            var dist = Math.sqrt(tox * tox + toy * toy);
            var k = 2 + d.age * 12;
            d.vx += (tox * k - d.vx * 2.4) * dt;
            d.vy += (toy * k - d.vy * 2.4) * dt;
            d.x += d.vx * dt;
            d.y += d.vy * dt;
            if ((dist < Rm * 0.5 && d.age > 0.6) || d.age > 8) {
              pulse = Math.min(0.12, pulse + 0.03 + (d.r / R) * 0.1);
              droplets.splice(i, 1);
            }
          }
        }

        function collectBlobs() {
          blobs.length = 0;
          var tail1x = clamp(m.vx * 0.035, -R * 1.2, R * 1.2);
          var tail1y = clamp(m.vy * 0.035, -R * 1.2, R * 1.2);
          blobs.push({ x: m.x, y: m.y, r: Rm });
          blobs.push({ x: m.x - tail1x, y: m.y - tail1y, r: Rm * 0.78 });
          blobs.push({ x: m.x - tail1x * 2, y: m.y - tail1y * 2, r: Rm * 0.55 });
          for (var i = 0; i < droplets.length; i++) {
            var d = droplets[i];
            blobs.push({ x: d.x, y: d.y, r: d.r });
          }
        }

        function buildField() {
          field.fill(0);
          var i, x, y;
          for (i = 0; i < blobs.length; i++) {
            var b = blobs[i];
            var cx = b.x / S;
            var cy = b.y / S;
            var rr = (b.r * KR) / S;
            var rr2 = rr * rr;
            var x0 = Math.max(0, Math.floor(cx - rr));
            var x1 = Math.min(lw - 1, Math.ceil(cx + rr));
            var y0 = Math.max(0, Math.floor(cy - rr));
            var y1 = Math.min(lh - 1, Math.ceil(cy + rr));
            for (y = y0; y <= y1; y++) {
              var dy = y - cy;
              var row = y * lw;
              for (x = x0; x <= x1; x++) {
                var dx = x - cx;
                var q = 1 - (dx * dx + dy * dy) / rr2;
                if (q > 0) {
                  field[row + x] += q * q * q;
                }
              }
            }
          }
          var md = maskData.data;
          var rd = rimData.data;
          md.fill(0);
          rd.fill(0);
          for (y = 1; y < lh - 1; y++) {
            for (x = 1; x < lw - 1; x++) {
              var idx = y * lw + x;
              var f = field[idx] * INV_T;
              if (f < 0.9) {
                continue;
              }
              var p = idx * 4;
              md[p] = 238;
              md[p + 1] = 241;
              md[p + 2] = 250;
              md[p + 3] = smooth(0.93, 1.07, f) * 255;
              var gx = (field[idx + 1] - field[idx - 1]) * INV_T;
              var gy = (field[idx + lw] - field[idx - lw]) * INV_T;
              var len = Math.sqrt(gx * gx + gy * gy) + 0.0001;
              var nx = -gx / len;
              var ny = -gy / len;
              var dl = nx * LX + ny * LY;
              var t = clamp((f - 1) / 0.8, 0, 1);
              var w = (1 - t) * (1 - t) * smooth(0.9, 1.0, f);
              if (dl > 0) {
                rd[p] = 255;
                rd[p + 1] = 255;
                rd[p + 2] = 255;
                rd[p + 3] = w * dl * 235;
              } else {
                rd[p] = 90;
                rd[p + 1] = 70;
                rd[p + 2] = 200;
                rd[p + 3] = w * -dl * 120;
              }
            }
          }
          maskCtx.putImageData(maskData, 0, 0);
          rimCtx.putImageData(rimData, 0, 0);
        }

        function render() {
          collectBlobs();
          buildField();

          // 1. animated gradient, cut into the headline shape (only the text band)
          gtx.setTransform(1, 0, 0, 1, 0, 0);
          gtx.globalCompositeOperation = "source-over";
          gtx.clearRect(0, band.y, cw, band.h);
          gtx.setTransform(dpr, 0, 0, dpr, 0, 0);
          var off = (time * 70) % gradW;
          for (var gx0 = off - gradW; gx0 < W; gx0 += gradW) {
            gtx.save();
            gtx.translate(gx0, 0);
            gtx.fillStyle = grad;
            gtx.fillRect(0, bandCss.y, gradW + 1, bandCss.h);
            gtx.restore();
          }
          gtx.setTransform(1, 0, 0, 1, 0, 0);
          gtx.globalCompositeOperation = "destination-in";
          gtx.drawImage(textCanvas, 0, band.y, cw, band.h, 0, band.y, cw, band.h);
          gtx.globalCompositeOperation = "source-over";

          // 2. refraction: magnify around the lens centre and wobble in thin strips
          lx.setTransform(1, 0, 0, 1, 0, 0);
          lx.globalCompositeOperation = "source-over";
          lx.clearRect(0, 0, cw, ch);
          lx.setTransform(dpr, 0, 0, dpr, 0, 0);
          var speed = Math.sqrt(m.vx * m.vx + m.vy * m.vy);
          var amp = 1.6 + Math.min(speed * 0.004, 4) + pulse * 20;
          lx.save();
          lx.translate(m.x, m.y);
          lx.scale(MAG, MAG);
          lx.translate(-m.x, -m.y);
          var yEnd = bandCss.y + bandCss.h - STRIP;
          for (var y = bandCss.y; y < yEnd; y += STRIP) {
            var dx =
              Math.sin(y * 0.05 + time * 2.6) * amp + Math.sin(y * 0.13 - time * 1.7) * amp * 0.5;
            lx.drawImage(gtCanvas, 0, y * dpr, cw, (STRIP + 0.6) * dpr, dx, y, W, STRIP + 0.6);
          }
          lx.restore();

          // 3. keep only what sits inside the metaball, add a pale glass body, shadow and rim light
          lx.setTransform(1, 0, 0, 1, 0, 0);
          lx.imageSmoothingEnabled = true;
          lx.imageSmoothingQuality = "high";
          var mw = lw * S * dpr;
          var mh = lh * S * dpr;
          lx.globalCompositeOperation = "destination-in";
          lx.drawImage(maskCanvas, 0, 0, mw, mh);
          lx.globalCompositeOperation = "destination-over";
          lx.shadowColor = "rgba(40, 50, 100, 0.28)";
          lx.shadowBlur = 34 * dpr;
          lx.shadowOffsetY = 14 * dpr;
          lx.drawImage(maskCanvas, 0, 0, mw, mh);
          lx.shadowColor = "rgba(0, 0, 0, 0)";
          lx.shadowBlur = 0;
          lx.shadowOffsetY = 0;
          lx.globalCompositeOperation = "source-over";
          lx.drawImage(rimCanvas, 0, 0, mw, mh);

          // 4. page: crisp black headline, then the lens on top
          ctx.setTransform(1, 0, 0, 1, 0, 0);
          ctx.clearRect(0, 0, cw, ch);
          ctx.drawImage(textCanvas, 0, 0);
          ctx.drawImage(lensCanvas, 0, 0);
        }

        function setupStill() {
          time = 3;
          Rm = R;
          pulse = 0;
          m.x = W * 0.42;
          m.y = headY;
          m.vx = 300;
          m.vy = -40;
          droplets = [];
          for (var i = 0; i < 4; i++) {
            droplets.push({
              x: m.x - R * (1.7 + i * 0.75),
              y: m.y + Math.sin(i * 1.7) * R * 0.5,
              vx: 0,
              vy: 0,
              r: R * (0.26 - i * 0.04),
              age: 1,
            });
          }
        }

        var last = performance.now();
        function frame(now) {
          requestAnimationFrame(frame);
          var P = pat();
          if (P.paused) {
            last = now;
            return;
          }
          if (P.reducedMotion) {
            last = now;
            if (dirty) {
              setupStill();
              render();
              dirty = false;
            }
            return;
          }
          var dt = Math.min((now - last) / 1000, 1 / 30);
          last = now;
          dirty = false;
          if (dt <= 0) {
            return;
          }
          step(dt);
          render();
        }

        try {
          resize();
          window.addEventListener("resize", resize);
          window.addEventListener("pointermove", onMove, { passive: true });
          window.addEventListener("mousemove", onMove, { passive: true });
          window.addEventListener("pointerdown", onDown, { passive: true });
          requestAnimationFrame(frame);
        } catch (err) {
          root.classList.add("fallback");
        }
      })();
    </script>
  </body>
</html>

Made by 6to9. Back to all motion patterns.