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.