This is the whole build of the liquid glass dock, in the order it was written. It is a dock of nine made-up apps over a liquid silk wallpaper. Move the pointer along it and the glass swells into a lens around the cursor: the icon under it grows to one and a half times its size, its neighbours grow less and move aside, and the bar widens with them. Click an icon and it lifts out of the glass, then settles back with ripple rings. Drag one and it comes out of the glass; let go and it settles into its new place. On a phone the same dock sits at the bottom of a home screen. Each stage below is the dock as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step: one HTML page, one style sheet, the controller (an ES module of about 700 lines), the icons (nine SVGs written in code) and one fragment shader of about 160 lines, drawn on two canvases. The icons, their labels and the tooltip are ordinary HTML elements. WebGL draws only the glass and its light. The wallpaper was generated with Google Gemini.
1 · The layout, before any glass
Start with the scene: the wallpaper, a home-screen grid, the dock and a caption. The glass will be drawn later; for
now its place is a plain div.
<picture id="wall" aria-hidden="true">
<source type="image/avif" srcset="img/wall-1280.avif 1280w, img/wall-max.avif 1365w" sizes="100vw">
<source type="image/webp" srcset="img/wall-1280.webp 1280w, img/wall-max.webp 1365w" sizes="100vw">
<img src="img/wall-max.jpg" alt="" width="1365" height="768" fetchpriority="high">
</picture>
<header class="phone-head">
<p class="brand" aria-hidden="true">Aether Phone</p>
<p class="sub">Liquid Glass Dock</p>
</header>
<div id="home" role="toolbar" aria-label="Home screen" aria-orientation="horizontal"></div>
<div id="glass-css" aria-hidden="true"></div>
<div id="dock" role="toolbar" aria-label="Dock" aria-orientation="horizontal"></div>
The nine icons are inline SVGs written in code, for made-up apps: a rounded-square body with a gradient, the glyph, a gloss on top and a thin bright rim. Being vectors, they stay sharp at any magnification.
{ id: 'mail', name: 'Mail', body: ['#68d0ff', '#1a6cf0'], glyph: (p) => `
<rect x="24" y="35" width="72" height="50" rx="9" fill="url(#${p}g)"/>
<path d="M27 39.5 56 61.5c2.4 1.8 5.6 1.8 8 0l29-22" fill="none" stroke="#7fb6f2" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M27.5 81 50 60M92.5 81 70 60" stroke="#a9cdf5" stroke-width="2.4" stroke-linecap="round"/>` },
const items = new Map();
for (const app of APPS) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'app'; b.dataset.id = app.id; b.setAttribute('aria-label', app.name);
b.innerHTML = `<span class="ico">${iconSVG(app)}</span><span class="lbl" aria-hidden="true">${app.name}</span>`;
items.set(app.id, { id: app.id, app, el: b, where: '', slot: 0, tf: '', vis: { x: 0, y: 0, s: 1 } });
}
All the apps are kept in one ordered list. The dock shows a window of that list: all nine on a desktop, five on a phone, centred so Mail sits in the middle. The apps on either side of the window are the home screen. Every later move, whether dragged or keyed, is one insertion into this list, so the dock and the home screen can never disagree.
// The dock is a window of the one ordered list: order[hl … hl+cap). The home screen is what lies either side of it.
let hl = 0;
const homeList = () => order.slice(0, hl).concat(order.slice(hl + M.cap));
const dockList = () => order.slice(hl, hl + M.cap);
measure() sizes everything from the screen: on a desktop an icon is 5.5 % of the width (80 px at 1440),
icons are 1.38 icons apart, and the dock's centre sits at 52 % of the height. Phones get a bottom dock with icons of up
to 64 px; a 390 px phone has five slots. The sizes go into CSS variables, so the style sheet follows.
function measure() {
const vw = innerWidth, vh = innerHeight;
M.vw = vw; M.vh = vh;
M.layout = vw < 600 || vh > vw * 1.05 ? 'home' : 'stage';
if (M.layout === 'stage') {
M.pr = 1.38; M.endR = 0.9;
M.icon = clamp(Math.min(vw * 0.0555, (vw * 0.86) / (M.pr * (N - 1) + 2.8), vh * 0.095), 50, 128); // board: icon ≈ 5.5 % of the width
M.cap = N; while (M.cap > 4 && M.icon * (M.pr * (M.cap - 1) + 2.8) > vw * 0.94) M.cap--;
M.hh = M.icon * 0.92; M.cy = Math.round(vh * 0.52);
} else {
M.pr = 1.26; M.endR = 0.55;
M.icon = clamp(Math.min(64, (vw - 24) / (M.pr * 4 + 2.1 + 0.02)), 46, 64);
M.cap = Math.min(N, Math.floor(((vw - 24) / M.icon - 2.1) / M.pr) + 1);
M.hh = M.icon * 0.86;
M.cy = Math.round(vh - 18 - M.hh * 1.3);
}
if (M.prevCap === undefined) hl = centred();
else if (M.prevCap !== M.cap) hl = clamp(Math.round(hl + (M.prevCap - M.cap) / 2), 0, N - M.cap);
M.prevCap = M.cap;
M.pitch = M.icon * M.pr; M.padEnd = M.icon * M.endR;
M.cx = vw / 2;
root.dataset.layout = M.layout;
root.style.setProperty('--icon', M.icon + 'px');
root.style.setProperty('--cy', M.cy + 'px');
root.style.setProperty('--hh', M.hh + 'px');
root.style.setProperty('--focus-x', (manifest.phoneFocus?.[0] ?? 0.5) * 100 + '%');
}
step() places every icon at its rest position with a transform, and spans the bar under them. On a
narrow screen the icons draw closer together so the dock fits, and the bar slides back on screen if it would stick out.
function step() {
const dock = dockList();
S.total = dock.length;
// narrow screens: the far icons draw closer, so the dock fits on screen
const usable = M.vw - 24 - M.icon - 2 * M.padEnd;
const pitch = Math.max(Math.min(M.pitch, usable / Math.max(S.total - 1, 1)), Math.min(M.pitch, M.icon * 1.04));
geom.pitch = pitch;
const uL = restU(0, S.total, pitch) - M.icon / 2 - M.padEnd, uR = restU(S.total - 1, S.total, pitch) + M.icon / 2 + M.padEnd;
geom.x0 = uL; geom.x1 = uR;
// keep the whole bar on screen: slide it in
const m = M.layout === 'home' ? 5 : 14, room = M.vw - 2 * m;
let off = 0;
if (geom.x0 < m) off = m - geom.x0; else if (geom.x1 > M.vw - m) off = M.vw - m - geom.x1;
if (geom.x1 - geom.x0 > room) off = (M.vw - geom.x0 - geom.x1) / 2;
geom.off = off; geom.x0 += off; geom.x1 += off;
// icons
for (const id of dock) {
const it = items.get(id);
const u = restU(it.slot, S.total, pitch);
let x = u + off, s = 1, y = M.cy;
it.vis = { x, y, s };
const tf = `translate(${(x - M.icon / 2).toFixed(2)}px, ${(y - M.icon / 2).toFixed(2)}px) scale(${s.toFixed(4)})`;
if (tf !== it.tf) { it.el.style.transform = tf; it.tf = tf; }
}
// the frosted bar spans the dock
const st = E.css.style; st.left = geom.x0 + 'px'; st.width = (geom.x1 - geom.x0) + 'px';
}
The bar is a frosted CSS panel: a translucent gradient, a backdrop blur and an inset highlight. Stage 3 draws real glass in its place but keeps this panel for browsers without WebGL.
.no-gl #glass-css { display: block; position: fixed; z-index: 2; top: calc(var(--cy) - var(--hh)); height: calc(var(--hh) * 2); border-radius: calc(var(--hh) * .94);
background: linear-gradient(to bottom, rgba(255, 255, 255, .22), rgba(255, 255, 255, .08) 45%, rgba(255, 255, 255, .16));
-webkit-backdrop-filter: blur(16px) saturate(1.6); backdrop-filter: blur(16px) saturate(1.6);
border: 1px solid rgba(255, 255, 255, .42);
box-shadow: inset 0 2px 0 rgba(255, 255, 255, .55), inset 0 -14px 24px rgba(255, 255, 255, .14), 0 24px 50px rgba(8, 4, 40, .5); }
#home { position: fixed; z-index: 5; left: 50%; transform: translateX(-50%); display: none; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px 8px; width: min(560px, calc(100% - 24px)); top: 150px; }
.has-home #home { display: grid; }
[data-layout='stage'] #home { left: 24px; transform: none; top: 24px; width: min(420px, 40vw); }

2 · Dock magnification
The magnification is the classic dock effect, worked out continuously. With the lens at px and an
amplitude A, an icon whose rest position is u is drawn at
u + A·σ·(√π/2)·erf((u − px)/σ), at a scale of 1 + A·exp(−((u − px)/σ)²). The position is the
integral of the scale, so the icons never overlap; the point under the cursor never moves; and the bar's two ends go
through the same map, so the bar widens by exactly what the icons need.
function mapX(u, A) { return u + A * M.sigma * SQP * erf((u - S.lens.x) / M.sigma); }
function scaleAt(u, A) { return 1 + A * Math.exp(-Math.pow((u - S.lens.x) / M.sigma, 2)); }
const SQP = Math.sqrt(Math.PI) / 2;
function erf(x) { const s = Math.sign(x); x = Math.abs(x); const t = 1 / (1 + 0.3275911 * x);
return s * (1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x)); }
The lens is a pair of springs on one clock. Its position follows the pointer stiffly; its amplitude is under-damped, so the swell overshoots a little and settles, the way liquid would. With reduced motion both springs are critically damped and the lens snaps to the pointer.
// lens: position follows quickly, amplitude is under-damped → the liquid overshoots and wobbles
const kx = S.hover || S.scrub ? 900 : 260, cx = 2 * Math.sqrt(kx) * (S.hover || S.scrub ? 1 : 0.9);
if (Math.abs(L.x - L.tx) > M.vw) L.x = L.tx;
const ka = reduced ? 600 : 170, ca = 2 * Math.sqrt(ka) * (reduced ? 1 : 0.52);
const sub = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / sub;
// slots
const dock = dockList();
S.total = dock.length;
for (let i = 0; i < sub; i++) {
L.v += (kx * (L.tx - L.x) - cx * L.v) * h; L.x += L.v * h;
L.av += (ka * (L.ta - L.a) - ca * L.av) * h; L.a += L.av * h;
}
if (reduced) L.x = L.tx;
On a phone the swell would push the end icons off the screen, so the far icons draw closer while the lens pushes its neighbours apart. If the bar would still be wider than the screen, the swell is damped first and the bar slides back.
let A = M.Amax * Math.max(L.a, -0.08);
// narrow screens: the far icons draw closer while the lens pushes its neighbours apart, so the swell fits on screen
const extra = M.layout === 'home' ? 2 * Math.max(A, 0) * M.sigma * SQP : 0;
const usable = M.vw - 24 - M.icon - 2 * M.padEnd - extra;
const pitch = Math.max(Math.min(M.pitch, usable / Math.max(S.total - 1, 1)), Math.min(M.pitch, M.icon * 1.04));
geom.pitch = pitch;
const uL = restU(0, S.total, pitch) - M.icon / 2 - M.padEnd, uR = restU(S.total - 1, S.total, pitch) + M.icon / 2 + M.padEnd;
geom.x0 = mapX(uL, A); geom.x1 = mapX(uR, A);
// keep the whole liquid on screen: first damp the swell if the bar would be wider than the screen, then slide it in
const m = M.layout === 'home' ? 5 : 14, room = M.vw - 2 * m, W0 = uR - uL, W = geom.x1 - geom.x0;
geom.k = 1;
if (W > room && W > W0 + 0.5) { geom.k = clamp((room - W0) / (W - W0), 0, 1); A *= geom.k; geom.x0 = mapX(uL, A); geom.x1 = mapX(uR, A); }
let off = 0;
if (geom.x0 < m) off = m - geom.x0; else if (geom.x1 > M.vw - m) off = M.vw - m - geom.x1;
if (geom.x1 - geom.x0 > room) off = (M.vw - geom.x0 - geom.x1) / 2;
geom.off = off; geom.x0 += off; geom.x1 += off;
The lens follows the pointer while it is over the bar. A finger works too: a touch on the dock starts a scrub, and the lens follows the finger until it lifts. On the first screen, before anyone points, the lens rests on Mail.
function updateHover() {
const { x, y, type } = S.pointer;
if (type !== 'mouse') { if (S.scrub) { updateLensTarget(); kick(); } return; }
const inside = Math.abs(y - M.cy) < M.hh * (S.hover ? 1.55 : 1.2) && x > geom.x0 - (S.hover ? 24 : 0) && x < geom.x1 + (S.hover ? 24 : 0);
if (inside !== S.hover) { S.hover = inside; if (inside) S.showcase = false; }
updateLensTarget(); kick();
}
function onDown(e) {
if (e.button > 0) return;
const btn = e.target.closest('.app');
const onHit = e.target === E.hit;
if (!btn && !onHit) return;
S.pointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
if (e.pointerType !== 'mouse') {
S.showcase = false;
if (btn && items.get(btn.dataset.id).where === 'dock' || onHit) { S.scrub = true; S.hover = true; updateLensTarget(); }
}
kick();
}
function updateLensTarget() {
const dock = dockList().map((id) => items.get(id));
const first = dock[0], last = dock[dock.length - 1];
const uMin = first ? restU(0, S.total, geom.pitch) : M.cx, uMax = last ? restU(S.total - 1, S.total, geom.pitch) : M.cx;
const pu = S.pointer.x - (geom.off || 0); // pointer in rest coordinates (the bar may be slid to stay on screen)
if (S.hover || S.scrub) { S.lens.tx = clamp(pu, uMin - M.pitch * 0.3, uMax + M.pitch * 0.3); S.lens.ta = 1; }
else if (S.showcase) { const id = dockList().includes('mail') ? 'mail' : dockList()[Math.floor(M.cap / 2)]; S.lens.tx = restU(dockList().indexOf(id), S.total || M.cap, geom.pitch || M.pitch); S.lens.ta = 0.85; }
else S.lens.ta = 0;
// the tooltip names the icon under the pointer / finger / focus
let hid = null;
if (S.hover || S.scrub) {
let best = 1e9; for (const it of dock) { const dd = Math.abs(it.vis.x - S.pointer.x); if (dd < best && dd < (M.icon * it.vis.s) / 2 + 6 && Math.abs(S.pointer.y - it.vis.y) < M.icon * it.vis.s * 0.62) { best = dd; hid = it.id; } }
}
S.hoverId = hid;
}
The loop runs only while something moves: kick() starts it, and it stops itself once every spring is at
rest. A tooltip, a small glass pill, names the icon under the pointer.
function tick(now) {
const dt = Math.min((now - last) / 1000, 1 / 20); last = now;
step(dt);
// keep running while anything moves
const L = S.lens;
const busy = Math.abs(L.a - L.ta) > 0.001 || Math.abs(L.av) > 0.001 || Math.abs(L.x - L.tx) > 0.05;
if (busy) requestAnimationFrame(tick); else running = false;
}
function showTip(it) {
if (tipId !== it.id) { E.tip.textContent = it.app.name; tipId = it.id; E.tip.classList.add('on'); }
const ampV = clamp(S.lens.a, 0, 1.2), bulge = M.cy - M.hh * (1 + 0.12 * ampV);
const top = Math.min(it.vis.y - (M.icon * it.vis.s) / 2, bulge, M.cy - M.hh) - 12;
E.tip.style.transform = `translate(${Math.round(it.vis.x)}px, ${Math.round(top)}px) translate(-50%, -100%)`;
}

3 · The liquid glass
Now the glass. It is drawn by one fragment shader on two canvases: one under the icons and one over them, so the icons sit inside the glass rather than on top of it.
<canvas id="glass-back" aria-hidden="true"></canvas>
<div id="dock" role="toolbar" aria-label="Dock" aria-orientation="horizontal" aria-describedby="dock-help">
<div id="dock-hit" aria-hidden="true"></div>
</div>
<canvas id="glass-front" aria-hidden="true"></canvas>
The shape is a 2D signed distance: a rounded slab for the bar, smoothly joined (a smooth minimum) to a low ellipse around the lens, so the outline swells gently instead of switching.
float sdRBox(vec2 p, vec2 b, float r){ vec2 q=abs(p)-b+r; return length(max(q,0.))+min(max(q.x,q.y),0.)-r; }
float sdEll(vec2 p, vec2 ab){ vec2 q=p/ab; float k0=length(q); if(k0<1.0) return (k0-1.0)*min(ab.x,ab.y); float k1=length(q/ab); return k0*(k0-1.0)/max(k1,1e-7); }
float smin(float a,float b,float k){ float h=max(k-abs(a-b),0.)/k; return min(a,b)-h*h*k*0.25; }
float shape(vec2 p){
float hh=uBar.w;
vec2 c=vec2((uBar.x+uBar.y)*0.5, uBar.z);
float d=sdRBox(p-c, vec2((uBar.y-uBar.x)*0.5, hh), hh*0.94);
if(uLens.y>0.002){
float e=sdEll(p-vec2(uLens.x,uBar.z), vec2(uLens.z,uLens.w));
d=smin(d,e, 1.0+hh*0.4*uLens.y); // a gentle swell of the silhouette, not a blob
}
return d;
}
The distance becomes a height field: a rounded rim (a quarter circle), an inner groove that gives the second highlight line, a slight crown on the floor, and a smooth swell of water under the lens. Its slope is worked out in closed form (only the shape's own gradient is a finite difference), about three times cheaper than taking differences of the whole height field, and it gives a real surface normal at every pixel.
vec3 surf(vec2 p, float d, vec2 gd){
float R=Re();
float t=max(-d,0.);
float x=clamp(t/R,0.,1.), ix=1.0-x;
float cs=sqrt(max(1.0-ix*ix,1e-4));
float h=R*cs;
float dh=(t<R)?min(ix/cs,8.0):0.0; // d h / d t of the rounded rim
float gz=(t-R*1.18)/(R*0.2); float ge=exp(-gz*gz);
h-=R*0.085*ge; dh+=0.085*ge*2.0*gz/0.2; // inner groove
float cu=clamp((t-R)/(R*0.8),0.,1.);
h+=R*0.05*cu*cu*(3.0-2.0*cu); dh+=0.05*6.0*cu*(1.0-cu)/0.8; // a slight crown on the floor
vec2 g=dh*(-gd); // t = -d
vec2 lc=vec2(uLens.x,uBar.z), rxy=vec2(uLens.z,uLens.w);
if(uLens.y>0.002){
vec2 q=(p-lc)/rxy; float q2=dot(q,q);
float sw=exp(-q2*1.3), dsw=-1.3*sw;
float k=uLens.y*R*1.25;
h+=k*sw; g+=k*dsw*2.0*q/rxy; // lens: water swelling smoothly under the icon
}
float f=smoothstep(0.0,1.2,t);
return vec3(h*f, g*f);
}
The back canvas looks through that surface. It reads the wallpaper offset along the normal (strongly at the rim, a little on the floor), splits red and blue slightly at the rim for dispersion, frosts the floor by sampling a blurrier mipmap level, magnifies what is under the lens, and mixes in a fresnel reflection at the curve.
vec2 lc=vec2(uLens.x,uBar.z);
vec2 q=(p-lc)/vec2(uLens.z,uLens.w);
float dome=uLens.y*exp(-dot(q,q)*1.7);
vec2 sp=p+(lc-p)*dome*0.30; // the lens magnifies what is under it
float tt=max(-d,0.);
float floorA=smoothstep(R*0.7,R*1.5,tt);
vec2 off=-n.xy*(R*0.9*(1.0-floorA*0.6)+H*0.3);
float ca=edge*R*0.10;
vec2 b=sp+off;
float bias=uFrost*floorA*(1.0-clamp(dome*1.6,0.,1.)); // the floor is lightly frosted; the lens and the rim are clear
vec3 bg=vec3(bgB(b+n.xy*ca,bias).r, bgB(b,bias).g, bgB(b-n.xy*ca,bias).b);
vec3 c=bg*vec3(0.93,0.98,1.06)*1.02+vec3(0.075,0.08,0.11); // clear glass, faint cool milky body
c=mix(c, c*vec3(0.5,0.56,0.8), smoothstep(0.55,0.97,edge)*0.7); // a thin dark line where the rim turns over
float F=0.05+0.95*pow(edge,2.6);
vec3 env=mix(vec3(0.86,0.9,1.0), bgAt(p+vec2(0.0,-uBar.w*3.2))*1.3+0.25, 0.35);
c=mix(c, env, clamp(F,0.,1.)*0.82); // silver rim: the room reflected in the curve
The front canvas draws only light: two speculars, the rim, and a soft band of light drifting along the bar. Over the icons it draws less, so the icons read crisply through the glass.
vec3 r=vec3(2.0*n.z*n.x, 2.0*n.z*n.y, 2.0*n.z*n.z-1.0);
vec3 L1=normalize(vec3(-0.32,-0.78,0.54));
vec3 L2=normalize(vec3(0.45,0.62,0.64));
float s1=max(dot(r,L1),0.0), s2=max(dot(r,L2),0.0);
float spec=pow(s1,70.0)*1.8+pow(s1,12.0)*0.12+pow(s2,45.0)*0.6;
float rim=pow(edge,1.8)*0.48+smoothstep(0.5,0.95,edge)*0.16;
float t=max(-d,0.0);
float floorA=smoothstep(R*0.8,R*2.0,t);
float band=exp(-pow((p.x-uSweep+(p.y-uBar.z)*0.75)/(uBar.w*0.85),2.0));
float glaze=0.035*floorA+band*0.075*(0.4+0.6*floorA);
vec3 env=bgAt(p+vec2(0.0,-uBar.w*2.6))*0.6+0.55;
// over the icons the surface light is softer, so the icons read crisp through the glass
float im=0.0;
for(int i=0;i<10;i++){ vec4 I=uIco[i]; if(I.z<=0.0) continue;
im=max(im, 1.0-smoothstep(-2.0*uDpr, 3.0*uDpr, sdRBox(p-I.xy, vec2(I.z*0.5), I.z*0.25))); }
float aa=clamp((spec+rim)*(1.0-0.55*im)+glaze*(1.0-0.7*im),0.0,0.92)*cov;
col=mix(env,vec3(1.0),clamp(spec*1.4,0.,1.))*aa; a=aa;
The shader samples the same wallpaper the page shows, so the refraction has to land exactly on the photo behind it:
the controller passes the object-fit: cover transform as a uniform.
// wallpaper cover transform for the glass: the shader maps canvas px → the same uv the <img> shows there
const ia = 16 / 9, posX = M.layout === 'home' ? (manifest.phoneFocus?.[0] ?? 0.5) : 0.5;
const sc = Math.max(vw / ia, vh); const Dw = sc * ia, Dh = sc;
const ox = (vw - Dw) * posX, oy = (vh - Dh) * 0.5;
M.bgXf = [1 / Dw, 1 / Dh, -ox / Dw, (M.ctop - oy) / Dh];
Every frame, the controller hands both canvases the same small state (the bar's ends, the lens, the icons for their shadows), and each canvas shades only a scissor box around the glass.
function frameState() {
const L = S.lens, t = S.t, ct = M.ctop;
const ampV = Math.max(L.a, 0);
const sweepSpan = geom.x1 - geom.x0 + 600;
return {
bgXf: M.bgXf,
bar: [geom.x0, geom.x1, M.cy - ct, M.hh],
lens: [L.x + (geom.off || 0), clamp(ampV * (geom.k ?? 1), 0, 1.35), M.sigma * (0.8 + 0.2 * Math.min(ampV, 1)), M.hh * (0.6 + 0.4 * Math.min(ampV, 1.2))],
icons: dockList().map((id) => { const v = items.get(id).vis; const lift = clamp(1 - (M.cy - v.y) / (M.icon * 0.9), 0, 1); return [v.x, v.y - ct, M.icon * v.s, lift]; }),
frost: 2.2,
sweep: reduced ? geom.x0 + (geom.x1 - geom.x0) * 0.3 : geom.x0 - 300 + ((t / 11) % 1) * sweepSpan,
};
}
draw(s, boxes) {
const gl = this.gl; if (!gl || !this.canvas.width) return;
const d = this.dpr, W = this.canvas.width, H = this.canvas.height, u = this.u;
gl.scissor(0, 0, W, H); gl.clear(gl.COLOR_BUFFER_BIT);
if (!boxes || !boxes.length) return;
const stat = `${W}|${H}|${d}|${s.bgXf.join(',')}`;
if (stat !== this._stat) {
this._stat = stat; gl.viewport(0, 0, W, H);
gl.uniform2f(u.uRes, W, H); gl.uniform1f(u.uDpr, d);
gl.uniform4f(u.uBgXf, s.bgXf[0] / d, s.bgXf[1] / d, s.bgXf[2], s.bgXf[3]);
}
gl.uniform4f(u.uBar, s.bar[0] * d, s.bar[1] * d, s.bar[2] * d, s.bar[3] * d);
gl.uniform4f(u.uLens, s.lens[0] * d, s.lens[1], s.lens[2] * d, s.lens[3] * d);
gl.uniform1f(u.uFrost, s.frost ?? 1.6); gl.uniform1f(u.uSweep, s.sweep * d);
const I = this.ico; I.fill(0);
s.icons.slice(0, 10).forEach((r, i) => { I[i * 4] = r[0] * d; I[i * 4 + 1] = r[1] * d; I[i * 4 + 2] = r[2] * d; I[i * 4 + 3] = r[3]; });
gl.uniform4fv(u.uIco, I);
for (const box of boxes) {
const sx = Math.max(0, Math.floor(box[0] * d)), sy = Math.max(0, Math.floor((this.h - box[3]) * d));
const sw = Math.min(W, Math.ceil(box[2] * d)) - sx, shh = Math.min(H, Math.ceil((this.h - box[1]) * d)) - sy;
if (sw <= 0 || shh <= 0) continue;
gl.scissor(sx, sy, sw, shh);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
Without WebGL (or with ?nogl in the URL) the frosted bar from stage 1 stays, with the same layout and the
same magnification.
function initGL() {
try {
layers = [new GlassLayer(E.back, false), new GlassLayer(E.front, true)];
glOK = layers.every((l) => l.ok);
} catch (err) { console.warn('glass: WebGL unavailable, using the CSS frosted bar', err); glOK = false; }
if (!glOK) layers = [];
root.classList.add(glOK ? 'gl' : 'no-gl');
}

4 · Water
A click opens an app. The icon lifts a third of its height out of the liquid and settles back. As it leaves and as it lands, the surface makes soft ripple rings and heaves gently where it lands. There are no droplets and no jets: the water only moves on its surface.
function activate(id) {
const it = items.get(id);
if (it.where === 'dock') { leap(it); say(`${it.app.name} opened`); if (!reduced) setTimeout(() => setRunning(it), 120); }
else { setRunning(it); it.el.animate([{ transform: 'scale(1)' }, { transform: 'scale(.86)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }], { duration: reduced ? 1 : 420, easing: 'ease-out' }); say(`${it.app.name} opened`); }
S.showcase = false;
}
if (it.leap) {
const t = S.t - it.leap.t0, lp = it.leap, hgt = lp.h;
const up = lp.kind === 'hop' ? 0.22 : 0.3, down = lp.kind === 'hop' ? 0.3 : 0.4;
if (!lp.s1) { lp.s1 = true; splash(x, lp.kind === 'hop' ? 0.35 : 0.5); }
if (t < up) { const e = easeOut3(t / up); y -= hgt * e; }
else if (t < up + down) { const q = (t - up) / down, e = q * q * (3 - 2 * q); y -= hgt * (1 - e); }
else { if (!lp.s2) { lp.s2 = true; ripple(x, lp.kind === 'hop' ? 0.3 : 0.45); } const k = t - up - down; y += M.icon * 0.025 * Math.sin(k * 14) * Math.exp(-k * 8); if (k > 0.4) it.leap = null; }
}
function ripple(x, amp = 1) { if (reduced) return; S.rips.push({ x, y: M.cy, t0: S.t, amp }); if (S.rips.length > 6) S.rips.shift(); kick(); }
// Ulvi's rule (2026-10-17): no droplets, no jets. Water only moves on the surface: soft rings + a gentle heave.
function splash(x, amp = 1) {
if (reduced) return;
ripple(x, amp * 0.8);
S.jets.push({ x, t0: S.t, amp }); if (S.jets.length > 2) S.jets.shift();
kick();
}
In the shader each ring is a travelling wave packet: a sine under a Gaussian envelope that spreads outwards, 24 px from crest to crest. The rings run only on the floor and the lens, never over the turned rim. Up to six at a time.
float fl=smoothstep(R*0.55,R*1.25,t); // ripples run on the floor and the lens, not over the turned rim
for(int i=0;i<6;i++){
vec4 Q=uRip[i]; if(Q.w<=0.0) continue;
vec2 v=p-Q.xy; float r=max(length(v),1e-3); float lam=24.0*uDpr, w=lam*1.05; // broad, few rings: water
float z=(r-Q.z)/w; if(abs(z)>3.0) continue;
float env=exp(-z*z); float ph=(r-Q.z)/lam*6.2831; float a=Q.w*R*0.30*fl;
h+=a*env*sin(ph);
g+=a*env*(cos(ph)*6.2831/lam-sin(ph)*2.0*z/w)*v/r;
}
if(abs(uJet.w)>0.001){ // the surface heaves where an icon settles in
vec2 v=(p-vec2(uJet.x,uBar.z))*vec2(1.0,1.3); float s2=uBar.w*0.9;
float e=exp(-dot(v,v)/(s2*s2)); float a=uJet.w*R*0.55;
h-=a*e; g+=a*e*2.0*(v*vec2(1.0,1.3))/(s2*s2);
}
A running dot appears under the opened app, and a live region reads it out for screen readers. On the first screen
Mail lifts once by itself, so the dock shows what it does before anyone touches it (?quiet turns that off).
With reduced motion there are no lifts and no ripples; the app still opens.
function say(msg) { clearTimeout(liveT); E.live.textContent = ''; liveT = setTimeout(() => { E.live.textContent = msg; }, 30); }
// the first screen: the lens rests on Mail and Mail leaps out of the liquid once
if (!QUIET && !reduced) setTimeout(() => { const m = items.get('mail'); if (m.where === 'dock' && S.showcase) leap(m, 0.32, 'intro'); }, 650);

5 · Drag to reorder
A press becomes a drag in three ways: a mouse that moves 6 px, a finger that holds still for 320 ms, or a finger that pulls up out of the dock (a sideways slide stays a scrub). The icon lifts out of the glass into a layer above it, so it can travel anywhere.
function onDown(e) {
if (e.button > 0 || drag) return;
const btn = e.target.closest('.app');
const onHit = e.target === E.hit;
if (!btn && !onHit) return;
S.pointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
press = { id: btn ? btn.dataset.id : null, x0: e.clientX, y0: e.clientY, type: e.pointerType, moved: false, timer: 0, scrub: false, pid: e.pointerId };
if (e.pointerType !== 'mouse') {
S.showcase = false;
if (btn && items.get(press.id).where === 'dock' || onHit) { S.scrub = true; S.hover = true; updateLensTarget(); }
if (btn) press.timer = setTimeout(() => { if (press && !press.moved && !drag) { const p = press; press = null; S.scrub = false; suppressClick = p.id; startDrag(p.id, S.pointer.x, S.pointer.y, p.type); } }, 320);
}
kick();
}
function startDrag(id, px, py, type) {
const it = items.get(id);
const from = it.where, r = liftRect(it);
drag = { id, from, ox: px - r.x, oy: py - r.y, x: r.x, y: r.y, s: r.s, vx: 0, origIndex: order.indexOf(id), type, lastX: px, tilt: 0 };
S.showcase = false; hideTip();
it.fly = null; it.leap = null;
it.el.classList.add('lifted');
if (from === 'home') { it.el.classList.remove('in-home'); it.el.classList.add('in-dock'); it.el.style.width = it.el.style.height = M.icon + 'px'; }
it.where = 'drag';
E.drag.appendChild(it.el);
if (document.activeElement !== it.el) it.el.focus({ preventScroll: true });
root.classList.add('dragging');
if (from === 'dock') { splash(r.x, 0.55); }
place(from === 'home');
updateGap(px, py);
say(`${it.app.name} picked up. Move and release to drop it, or press Escape to cancel.`);
kick();
}
The lifted icon gets its own compositor layer. Its drop shadow is drawn once and the GPU moves it, because repainting a filtered icon on every pointer move cost about 80 ms a frame on a phone.
/* the lifted icon is its own compositor layer: its shadow is rasterised once and the GPU moves it (repainting a
filtered icon every pointer move cost ~80 ms frames at DPR 3). It is rasterised at the lifted size, so it stays sharp. */
.app.lifted { cursor: grabbing; will-change: transform; }
.app.lifted .ico { filter: drop-shadow(0 20px 18px rgba(6, 2, 30, .45)); }
While it moves, the dock works out where it would land and opens a gap there; on the home screen a dashed cell shows the spot. The gap is measured from the rest positions, not the magnified ones, so the lens opening over the gap cannot feed back into where the gap is.
function updateGap(px, py) {
const prevGap = gap, prevHome = homeGap;
const dock = dockList().filter((id) => id !== drag.id), home = homeList().filter((id) => id !== drag.id);
gap = -1; homeGap = -1;
if (zoneDock(px, py)) {
// from rest positions (no lens): the lens opens over the gap, so measuring the swollen layout would feed back
const pu = px - (geom.off || 0), pitch = geom.pitch || M.pitch;
let k = 0; for (let i = 0; i < dock.length; i++) if (restU(i, dock.length, pitch) < pu) k++;
gap = k;
} else if (root.classList.contains('has-home') || M.cap < N) {
const hr = E.home.getBoundingClientRect();
if (px > hr.left - 30 && px < hr.right + 30 && py > hr.top - 40 && py < hr.bottom + 60) {
const cells = [...E.home.querySelectorAll('.app')];
let k = home.length;
for (let i = 0; i < cells.length; i++) { const r = cells[i].getBoundingClientRect(); if (py < r.bottom && (px < r.left + r.width / 2 || py < r.top)) { k = i; break; } }
homeGap = k;
}
}
if (homeGap !== prevHome) place(true);
if (gap !== prevGap) kick();
}
if (drag && gap >= 0) { S.lens.tx = restU(gap, S.total, geom.pitch || M.pitch); S.lens.ta = 0.72; } // the glass swells where it will land
On release the drop is one insertion into the list. A full dock that gains an app pushes the app at its far end out to the home screen; a dock that loses one pulls its neighbour in.
if (!cancel && gap >= 0) { // into the dock at `gap`; a full dock lets the end farther from the drop spill home
rest.splice(h + gap, 0, d.id); to = 'dock';
if (!wasDock && gap >= M.cap / 2) h += 1;
} else if (!cancel && homeGap >= 0) { // onto the home screen; a dock that lost an app pulls in its neighbour
if (homeGap <= h) { rest.splice(homeGap, 0, d.id); h += 1; }
else rest.splice(homeGap + (wasDock ? M.cap - 1 : M.cap), 0, d.id);
to = 'home';
} else { rest = order.slice(); h = hl; }
The icon then glides from the hand to just above its slot and settles in. Only when it touches the surface does it go back inside the glass, with a ripple.
if (it.fly) {
const f = it.fly, t = S.t - f.t0, T1 = 0.28, T2 = 0.52, T3 = 0.9;
const sy = y;
if (t < 0) { x = f.fx; y = f.fy; s = f.fs; r = f.fr; }
else if (t < T1) { const e = easeOut3(t / T1); x = lerp(f.fx, x, e); y = lerp(f.fy, sy - M.icon * 0.3, e); s = lerp(f.fs, s * 1.04, e); r = lerp(f.fr, 0, e); }
else if (t < T2) { const q = (t - T1) / (T2 - T1), e = q * q * (3 - 2 * q); y = lerp(sy - M.icon * 0.3, sy, e); s *= lerp(1.04, 1, e);
if (q > 0.45 && !f.impact) { f.impact = true; E.dock.appendChild(it.el); place(false); splash(x, 0.8); } }
else { if (!f.impact) { f.impact = true; E.dock.appendChild(it.el); place(false); splash(x, 0.8); } const k = t - T2; y = sy + M.icon * 0.03 * Math.sin(k * 14) * Math.exp(-k * 8); if (t > T3) it.fly = null; }
}
Escape puts it back, and Reset order brings the board order back.

6 · The keyboard, a saved order, and the finish
The dock is a toolbar with one tab stop (a roving tabindex), and so is the home screen. A skip link leads
to it.
<a class="skip" href="#dock">Skip to the dock</a>
function roving() {
const dock = dockList().filter((id) => !drag || drag.id !== id), home = homeList().filter((id) => !drag || drag.id !== id);
if (!dock.includes(focusDock)) focusDock = dock.includes('mail') ? 'mail' : dock[0];
if (!home.includes(focusHome)) focusHome = home[0] || null;
for (const [id, it] of items) it.el.tabIndex = id === focusDock || id === focusHome ? 0 : -1;
}
The arrow keys, Home and End move along the dock (the arrows wrap; up and down move by rows on the home screen), Enter opens the app, and Alt with an arrow moves it one place, also between the dock and the home screen.
function onKey(e) {
const b = e.target.closest?.('.app');
if (e.key === 'Escape' && drag) { e.preventDefault(); endDrag(true); return; }
if (!b || drag) return;
const id = b.dataset.id, it = items.get(id);
const list = it.where === 'dock' ? dockList() : homeList();
const i = list.indexOf(id);
const cols = 4;
let j = null;
if (e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { e.preventDefault(); moveBy(id, e.key === 'ArrowLeft' ? -1 : 1); return; }
if (e.key === 'ArrowRight') j = (i + 1) % list.length;
else if (e.key === 'ArrowLeft') j = (i - 1 + list.length) % list.length;
else if (e.key === 'Home') j = 0;
else if (e.key === 'End') j = list.length - 1;
else if (it.where === 'home' && e.key === 'ArrowDown') j = Math.min(list.length - 1, i + cols);
else if (it.where === 'home' && e.key === 'ArrowUp') j = Math.max(0, i - cols);
if (j === null) return;
e.preventDefault();
const nid = list[j];
if (it.where === 'dock') focusDock = nid; else focusHome = nid;
roving(); items.get(nid).el.focus();
}
function moveBy(id, dir) {
const i = order.indexOf(id), j = i + dir;
if (j < 0 || j >= N) return;
const prevDock = dockList(), it = items.get(id);
const prevVis = new Map([...items].map(([k, o]) => [k, { ...o.vis }]));
order.splice(i, 1); order.splice(j, 0, id);
const nowDock = dockList();
const crossed = prevDock.includes(id) !== nowDock.includes(id);
// an app that changes group: if it lands in the dock it hops in; if it leaves, the home grid FLIPs it in
for (const k of nowDock) if (!prevDock.includes(k)) { const o = items.get(k); const r = o.where === 'home' ? liftRect(o) : prevVis.get(k); o._pull = r; }
for (const k of prevDock) if (!nowDock.includes(k)) items.get(k)._from = prevVis.get(k);
place(false);
for (const k of nowDock) { const o = items.get(k); if (o._pull) { const r = o._pull; o._pull = null; o.slot = nowDock.indexOf(k); o.sv = 0; if (!reduced) { E.drag.appendChild(o.el); o.fly = { t0: S.t, fx: r.x, fy: r.y, fs: r.s, fr: 0, impact: false }; } } }
for (const k of homeList()) { const o = items.get(k); if (o._from && !reduced) { const f = o._from; const r1 = o.el.querySelector('.ico').getBoundingClientRect();
o.el.animate([{ transform: `translate(${f.x - r1.left - r1.width / 2}px, ${f.y - r1.top - r1.height / 2}px) scale(${(f.s * M.icon) / r1.width})` }, { transform: 'none' }], { duration: 380, easing: 'cubic-bezier(.2,.9,.25,1.1)' }); ripple(f.x, 0.4); }
o._from = null; }
if (!crossed && it.where === 'dock') leap(it, 0.2, 'hop');
if (it.where === 'dock') focusDock = id; else focusHome = id;
roving(); it.el.focus({ preventScroll: true });
saveOrder();
const where = it.where === 'dock' || it.where === 'drag' ? `dock position ${nowDock.indexOf(id) + 1} of ${nowDock.length}` : `home screen position ${homeList().indexOf(id) + 1}`;
say(`${it.app.name} moved to ${where}.`);
kick();
}
A keyboard user should see the dock work as well, so the lens follows keyboard focus too, with the tooltip.
E.dock.addEventListener('focusin', (e) => {
const b = e.target.closest('.app'); if (!b) return;
focusDock = b.dataset.id; roving();
if (b.matches(':focus-visible')) { S.focusLens = b.dataset.id; S.showcase = false; }
updateLensTarget(); kick();
});
Last, the order is saved. The key is versioned (dv55.layout.v1), anything older is wiped, an order that
does not hold exactly the nine apps is ignored, and the board order is never stored at all.
function loadOrder() {
try {
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('dv55.') && k !== KEY) localStorage.removeItem(k); }
const o = JSON.parse(localStorage.getItem(KEY) || 'null');
if (o && Array.isArray(o.order) && o.order.length === N && DEFAULT.every((id) => o.order.includes(id))) { stored = o; return o.order.slice(); }
} catch { /* storage blocked: defaults */ }
return DEFAULT.slice();
}
function saveOrder() { try { if (isDefault()) localStorage.removeItem(KEY); else localStorage.setItem(KEY, JSON.stringify({ order, hl, cap: M.cap })); } catch { /* ignore */ } }
Measured with the project's frame probe through hovering, launches, drags and the keyboard: no missed frames at
1440 × 900 or on a 390 × 844 phone, and one missed frame with the CPU slowed four times. The final build also has
?pose=normal|hover|splash|drop to freeze each state for screenshots, and window.__dock for
its tests.


Make it yours
- Your apps are
APPSinicons.js: an id, a name, the two colours of the body and a glyph drawn in a 120 × 120 SVG box. Where the screen is too narrow for all of them, the dock shows as many as fit and the rest go to the home screen. - Your wallpaper: replace the images in
img/(thepictureon the page andwall-gl.jpg, the copy the glass samples). The shader works in the photo's own coordinates, so any 16:9 image lines up. - The feel is a handful of numbers:
M.Amax(how much the lens magnifies),M.sigma(how wide it reaches), the lens springs instep(), and the lift height inleap(). - Accessibility is already there: real buttons with names, a toolbar with roving focus, keyboard reordering,
a live region, a visible focus ring, 44 px targets on touch, a frosted fallback without WebGL, and
prefers-reduced-motionhonoured.
The code is on GitHub at UlviShabanbayli/domvybe-liquid-dock, under the MIT licence: use it in your own projects, commercial ones included. The apps are made up. The wallpaper was generated with Google Gemini; the icons and the glass were made in code for this project.
Give the glass and the icons one map. The bar's ends, the icons and the lens all go through the same erf curve, so the liquid always widens by exactly what the icons need, and nothing has to be patched afterwards.