This is the whole build of the liquid glass player, in the order it was written: the now-playing card of a made-up music app, AURA SOUND. It is a thick slab of clear glass floating over the album's wallpaper. The glass bends and slightly magnifies what is behind it, its side walls catch prism colours, the play button is a glass puck seated in it, and the progress bar is a glowing liquid thread you can drag. Change the album and the wallpaper, the glass's tint and the thread's colours all cross-fade together. Each stage below is the page 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, one ES module for the player (about 670 lines) and one for the glass (a single WebGL2 shader pass). The wallpaper and the four album covers were generated with Google Gemini; every bit of glass, light and text is made in code, and every word on the glass is real DOM text above the canvas, so it stays sharp.
1 · The page and the slab, in CSS frost
The wallpaper is a fixed layer of photos under everything. There are two layers, so that later a new album can fade in over the old one.
<div class="wall" aria-hidden="true">
<div class="wall-layer is-on" data-wall="0"><img alt="" decoding="async" fetchpriority="high"></div>
<div class="wall-layer" data-wall="1"><img alt="" decoding="async"></div>
</div>
The slab is one absolutely placed box. JavaScript only ever sets five numbers on it: --x,
--y, --w, --h and --u, one percent of its height. Every size inside it
is written in --u, so the whole card scales as one piece and later morphs as one piece.
.slab { --x: 0px; --y: 0px; --w: 800px; --h: 330px; --u: 3.3px; --ex: 26px; --ey: 22px;
position: absolute; left: var(--x); top: var(--y); width: var(--w); height: var(--h); pointer-events: auto; transform-origin: 0 0;
border-radius: calc(8.4 * var(--u)); }
.art { position: absolute; margin: 0; left: calc(7.6 * var(--u)); top: calc(10 * var(--u)); width: calc(60 * var(--u)); height: calc(60 * var(--u));
border-radius: calc(3.2 * var(--u)); overflow: hidden; background: #2b1f4f;
box-shadow: 0 calc(2.4 * var(--u)) calc(6 * var(--u)) rgba(20, 6, 40, .42), 0 0 0 1px rgba(255, 255, 255, .22); }
The box is computed for the screen: about 62 % of a wide viewport, 2.42 : 1, a little left of centre because the wallpaper's subject lives on the right. On a phone it becomes a full-height sheet.
function geom() {
const vw = innerWidth, vh = innerHeight;
const cs = getComputedStyle(safe), st = parseFloat(cs.paddingTop) || 0, sb = parseFloat(cs.paddingBottom) || 0;
if (S.phone) {
return { x: 12, y: 12 + st, w: vw - 24, h: vh - 24 - st - sb, r: 34, bevel: 16, ex: 5, ey: 7 };
}
const frac = vw >= 1100 ? .62 : vh > vw ? .9 : .82;
let h = Math.min(vw * frac / 2.42, vh * .47, 520, (vh - 130) / 1.16);
h = Math.max(h, 120);
const w = h * 2.42;
const cx = vw >= 1100 ? vw * .455 : vw / 2; // a little left of centre: the wallpaper's subject lives on the right
return { x: Math.round(cx - (w + h * .075) / 2), y: Math.round((vh - h - h * .062) / 2 + vh * .012), w, h, r: h * .084, bevel: h * .055, ex: h * .075, ey: h * .062 };
}
Each side of the box is a spring, so any change of layout glides instead of jumping. They are written to the DOM only when the target changes; while a spring is moving, the DOM gets a compositor transform instead, so text is never laid out again mid-motion.
class Spring {
constructor(v, k = 170, zeta = .7) { this.v = v; this.t = v; this.vel = 0; this.k = k; this.c = 2 * Math.sqrt(k) * zeta; }
step(h) { this.vel += (this.k * (this.t - this.v) - this.c * this.vel) * h; this.v += this.vel * h; }
snap(v = this.t) { this.v = this.t = v; this.vel = 0; }
get rest() { return Math.abs(this.t - this.v) < .01 && Math.abs(this.vel) < .05; }
}
const box = { x: new Spring(0, 190, .66), y: new Spring(0, 190, .66), w: new Spring(800, 170, .62), h: new Spring(330, 170, .62) };
const par = { x: new Spring(0, 30, 1), y: new Spring(0, 30, 1) };
if (boxDirty) {
boxDirty = false;
const gu = S.phone ? 1 : G.h / 100, gr = S.phone ? G.r : G.h * .084;
setVar(slab, '--x', `${Math.round(G.x)}px`); setVar(slab, '--y', `${Math.round(G.y)}px`);
setVar(slab, '--w', `${+G.w.toFixed(2)}px`); setVar(slab, '--h', `${+G.h.toFixed(2)}px`);
setVar(slab, '--u', `${+gu.toFixed(4)}px`);
setVar(slab, 'border-radius', `${gr.toFixed(1)}px`);
if (S.phone) setVar(slab, '--art', `${Math.round(Math.min(G.w - 64, G.h * .42, 420))}px`);
}
The slab is thick: its right and bottom walls show. The wall depth drifts slowly and leans towards the pointer, and the engraved album name on the walls follows in half-pixel steps, so the style work stays off most frames.
const qx = Math.round(ex * 2) / 2, qy = Math.round(ey * 2) / 2;
if (last.qx !== qx || last.qy !== qy) {
last.qx = qx; last.qy = qy;
setVar(slab, '--ex', `${qx}px`); setVar(slab, '--ey', `${qy}px`);
setVar(slab, '--skr', `${(Math.atan2(qy, qx) * 180 / Math.PI).toFixed(1)}deg`);
setVar(slab, '--skb', `${(Math.atan2(qx, qy) * 180 / Math.PI).toFixed(1)}deg`);
}
Without WebGL the slab is CSS frost: a translucent gradient, a bright rim, a blurred backdrop and a hard offset shadow for the thickness. This is stage 1, and it stays the fallback for every browser without WebGL2.
.no-gl .face { background: linear-gradient(145deg, rgba(255, 255, 255, .3), rgba(255, 255, 255, .08) 45%, rgba(255, 255, 255, .16));
border: 1.5px solid rgba(255, 255, 255, .55); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7), inset 0 -2px 6px rgba(255, 255, 255, .2), var(--ex) var(--ey) 0 -1px rgba(255, 255, 255, .22), 0 30px 70px rgba(20, 6, 50, .35);
-webkit-backdrop-filter: blur(14px) saturate(1.5) brightness(1.06); backdrop-filter: blur(14px) saturate(1.5) brightness(1.06); }

2 · Real glass, in WebGL
A canvas covers the screen under the text. One WebGL2 pass draws the glass, scissored to the slab and its shadow, so the rest of the screen costs nothing. If WebGL2 is missing or fails, the page keeps the stage-1 frost.
// ------------------------------------------------------------------ glass
let glass = null;
try { glass = new Glass(canvas); if (glass.failed) glass = null; } catch (e) { console.warn('glass off:', e.message); glass = null; }
if (params.has('nogl')) glass = null;
if (!glass) root.classList.add('no-gl');
The glass needs to see what is behind it, so the wallpaper photo is also uploaded as a mipmapped texture (the mip
levels give frost for free), placed exactly like CSS object-fit: cover places the photo.
async function uploadTrackTex(t, k) {
if (!glass) return;
const key = `${t.id}:${k}`;
if (glass.has(key)) return;
const im = await loadImg(texUrl(t, k));
if (texKind !== k) return;
glass.upload(key, im);
setFit(slot, fit) {
const W = this.w * this.dpr, H = this.h * this.dpr;
if (!W || !H) return;
if (fit === 'fill') { this.xf[slot] = [1 / W, 1 / H, 0, 0]; return; }
const ia = fit, va = W / H;
if (va > ia) { const ih = W / ia; this.xf[slot] = [1 / W, 1 / ih, 0, .5 - H / 2 / ih]; }
else { const iw = H * ia; this.xf[slot] = [1 / iw, 1 / H, .5 - W / 2 / iw, 0]; }
}
The shape is a rounded box, as a signed distance: negative inside, positive outside, zero on the edge.
float sdBox(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; }
The front face is a height field: flat in the middle, a circular bevel at the rim. Its slope gives a real normal, the normal bends the lookup into the texture inward at the rim, and the whole face samples the wallpaper 1.075 times closer to the centre: the slab magnifies what is behind it.
// ---------------- front face ----------------
float covF = clamp(.5 - dF, 0., 1.);
if (covF > 0.) {
vec2 g = gBox(p - c, b, R);
float t = clamp(-dF, 0., B); float x = 1. - t / B;
float slope = min(x / max(sqrt(1. - x * x), .1), 6.);
vec3 n = normalize(vec3(g * slope, 1.));
// ripple through the slab
if (uRip.w > .001) {
float rd = length(p - uRip.xy);
float wv = uRip.w * exp(-pow((rd - uRip.z) / (14. * dpr), 2.)) * sin((rd - uRip.z) / (4.5 * dpr));
n = normalize(n + vec3(normalize(p - uRip.xy + 1e-3) * wv, 0.));
}
float rim = 1. - n.z;
vec2 q = c + (p - c) / 1.075; // the slab magnifies what is behind it
vec2 off = -n.xy * (B * 2.4 + H * .08); // the bevel bends light inward
At the rim the three colour channels are read at slightly different offsets, which is what glass does to light.
vec3 bgC(vec2 p, vec2 off, float bias, float spread){
return vec3(bg(p + off * (1. - spread), bias).r, bg(p + off, bias).g, bg(p + off * (1. + spread * 1.3), bias).b);
}
The walls are the face swept along the extrusion vector. Each pixel finds the wall it is looking at with a short march and a bisection, and the wall bends the wallpaper hard, with prism colours where it turns a corner.
// ---------------- side walls (oblique extrusion) ----------------
float tHit = -1.;
float dMin = dF;
// the swept walls live inside the box grown by |e|/2 around c + e/2: everything else skips the search
if (dF > -1. && sdBox(p - c - e * .5, b + abs(e) * .5, R) < 1.) {
for (int i = 1; i <= 14; i++) {
float tt = float(i) / 14.;
float dd = sdBox(p - c - e * tt, b, R);
dMin = min(dMin, dd);
if (dd < 0.) {
float lo = float(i - 1) / 14., hi = tt;
for (int k = 0; k < 4; k++) { float m = (lo + hi) * .5; if (sdBox(p - c - e * m, b, R) < 0.) hi = m; else lo = m; }
tHit = hi; break;
}
}
if (tHit < 0.) tHit = 1.;
The puck and the thread are drawn where the DOM button and slider are, measured once per layout.
function measure() {
const r = slab.getBoundingClientRect(), p = playBtn.getBoundingClientRect(), sc = scrub.getBoundingClientRect();
L = { slab: r, play: { x: p.left + p.width / 2, y: p.top + p.height / 2, r: Math.min(p.width, p.height) / 2 },
thread: { x0: sc.left + (S.phone ? 2 : 0), x1: sc.right - (S.phone ? 2 : 0), y: sc.top + sc.height / 2 } };
layoutDirty = false;
}

3 · Play, and the liquid thread
There is no audio file: a simulated clock plays the track. It advances in the frame loop, so it pauses with the tab.
if (S.playing && !S.scrubbing) {
S.t += dt;
if (S.t >= T().dur - .05) S.t = 0; // one track: it loops
}
The glass button plays and pauses. The glyph is DOM (a play and a pause SVG that morph), and a ripple runs through the slab from the button.
function setPlaying(on, say = true) {
S.playing = on;
root.classList.toggle('is-playing', on);
playBtn.setAttribute('aria-label', on ? 'Pause' : 'Play');
ripple(drop.hx, drop.hy, .7);
if (say) announce(on ? `Playing ${T().title}` : 'Paused');
}
if (rip) {
const age = (now - rip.t0) / 1000;
out.rip[0] = rip.x; out.rip[1] = rip.y; out.rip[2] = age * 380; out.rip[3] = rip.s * .55 * Math.exp(-age * 1.5);
if (age > 2.4) rip = null;
} else out.rip[3] = 0;
The progress bar is a real role="slider". A pointer captures it, a drag follows, a click jumps; the
keys follow the slider pattern.
function scrubAt(x) {
const th = L.thread; const f = clamp((x - th.x0) / (th.x1 - th.x0), 0, 1);
S.t = f * (T().dur - .05); updateTime(true);
}
scrub.addEventListener('pointerdown', (e) => {
if (e.button !== 0 && e.pointerType === 'mouse') return;
scrubId = e.pointerId; scrub.setPointerCapture(e.pointerId);
S.scrubbing = true; slab.classList.add('is-scrubbing'); measure(); scrubAt(e.clientX); e.preventDefault();
});
scrub.addEventListener('pointermove', (e) => { if (e.pointerId === scrubId) scrubAt(e.clientX); });
const endScrub = (e) => { if (e.pointerId !== scrubId) return; scrubId = null; S.scrubbing = false; slab.classList.remove('is-scrubbing'); ripple(bead.x, L.thread.y, .35); };
scrub.addEventListener('pointerup', endScrub); scrub.addEventListener('pointercancel', endScrub);
scrub.addEventListener('keydown', (e) => {
const d = T().dur; let t = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') t = S.t + 5;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') t = S.t - 5;
else if (e.key === 'PageUp') t = S.t + 30;
else if (e.key === 'PageDown') t = S.t - 30;
else if (e.key === 'Home') t = 0;
else if (e.key === 'End') t = d - 1;
if (t !== null) { seek(t); e.preventDefault(); e.stopPropagation(); }
});
In the shader the played part is a glowing filament that waves slowly, joined to a small glass knob by a smooth union, so when the knob is dragged the liquid trails behind it instead of breaking. The unplayed part is a groove cut into the glass.
float wave = (sin(p.x / (9. * dpr) - uTime * 4.2) * .16 + sin(p.x / (3.7 * dpr) + uTime * 2.6) * .06) * uWave;
float fx = max(x0, min(tx, bx));
float dFil = sdSeg(p, vec2(x0, ty), vec2(fx, ty)) - tr * (1.2 + wave);
float dHead = length(p - vec2(bx, by)) - rb;
float dTail = length(p - vec2(tx, ty)) - rb * .62;
float dBd = smin(dHead, smin(dTail, sdSeg(p, vec2(bx, by), vec2(tx, ty)) - rb * .38, rb * .7), rb * .9);
float dT = smin(dFil, dBd, rb * 1.1);

4 · Every album re-tints the glass
Changing the album changes everything at once: the CSS colours, the titles (out, swap, in), the art, the wallpaper, and a ripple from the button. Previous, next, N, B and the end of a track all come here.
function changeTrack(i, { dir = 1, auto = false } = {}) {
i = (i + TRACKS.length) % TRACKS.length;
if (i === S.i) { seek(0); return; }
S.i = i; S.t = 0;
const t = T();
// colours
colFrom = { tint: [...col.tint], accA: [...col.accA], accB: [...col.accB], glow: [...col.glow] }; colTo = { tint: rgb(t.tint), accA: rgb(t.a), accB: rgb(t.b), glow: rgb(t.glow) };
applyCssColours(t);
// text: out, swap, in
clearTimeout(metaTimer);
meta.classList.add('is-out');
metaTimer = setTimeout(() => { setText(t); meta.classList.remove('is-out'); }, reduced ? 0 : 260);
setArt(t);
goScene('wall');
ripple(drop.hx, drop.hy, .9);
announce(`${auto ? 'Up next: ' : ''}${t.title} by ${t.artist}`);
mediaSession();
updateTime(true);
}
The new wallpaper fades in on the second layer, and the shader gets it on its second texture unit.
async function goScene(type) {
finishScene();
const t = T(), k = kind();
let nxt;
if (type === 'wall') {
wallFlip = wallEls.indexOf(scene.cur.el) === 0 ? 1 : 0;
const el = scene.cur.type === 'wall' ? wallEls[wallFlip] : wallEls[wallFlip];
setWallImg(el, t);
await loadImg(wallUrl(t)).catch(() => {});
await $('img', el).decode().catch(() => {}); // the layer's own image is decoded before it fades in
await uploadTrackTex(t, k);
nxt = { type, el, key: `${t.id}:${k}`, fit: fitOf(k) };
}
finishScene();
if (scene.cur.el === nxt.el) { scene.cur = nxt; if (glass) { glass.bind(0, nxt.key); glass.setFit(0, nxt.fit); } return; }
nxt.el.style.zIndex = 2; nxt.el.style.opacity = 0;
scene.nxt = nxt; scene.p = 0; scene.t0 = performance.now();
if (glass) { glass.bind(1, nxt.key); glass.setFit(1, nxt.fit); }
if (reduced) finishScene();
}
Every frame of the fade, the layer's opacity and the shader's colours move on the same eased curve, so the photo under the glass and the photo inside it never disagree.
// scene cross-fade + colour tween
if (scene.nxt) {
scene.p = clamp((now - scene.t0) / scene.dur, 0, 1);
const e = ease(scene.p);
scene.nxt.el.style.opacity = e.toFixed(3);
if (colFrom && colTo) for (const k of COLS) for (let j = 0; j < 3; j++) col[k][j] = lerp(colFrom[k][j], colTo[k][j], e);
if (scene.p >= 1) finishScene();
}
vec3 bg(vec2 p, float bias){
vec3 a = texture(uTexA, p * uXfA.xy + uXfA.zw, bias).rgb;
if (uMix < .002) return a;
vec3 b = texture(uTexB, p * uXfB.xy + uXfB.zw, bias).rgb;
return mix(a, b, uMix);
}
.meta.is-out > * { opacity: 0; transform: translateY(calc(-2 * var(--u))); }
.meta.is-out .artist { transform: translateY(calc(2 * var(--u))); }

5 · Hover, and minimize
Hovering the slab, or moving focus into it, lifts the glass (thicker walls, a longer shadow), slides shuffle and repeat out beside the controls and shows the times. On touch screens they are always there.
slab.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') { S.hover = true; syncHover(); } });
slab.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') { S.hover = false; syncHover(); } });
slab.addEventListener('focusin', () => { S.focusIn = true; syncHover(); });
slab.addEventListener('focusout', () => { S.focusIn = slab.contains(document.activeElement); setTimeout(() => { S.focusIn = slab.contains(document.activeElement); syncHover(); }, 0); });
function syncHover() { const on = S.hover || S.focusIn; slab.classList.toggle('is-hover', on); lift.t = on ? 1 : 0; layoutDirty = true; }
.is-hover .ctl-x, .is-touch .ctl-x { width: max(44px, calc(10 * var(--u))); opacity: 1; pointer-events: auto; margin: calc(-6 * var(--u)) 0; transform: none; }
The handle on the bottom wall (or M) minimizes the player to a compact card docked low. Only the target box changes; the springs do the rest.
if (S.mini) {
h = clamp(h * .42, 120, 150); const w = h * 2.42;
return { x: Math.round((vw - w - h * .05) / 2), y: Math.round(vh - h - Math.max(64, vh * .08)), w, h, r: h * .13, bevel: h * .07, ex: h * .05, ey: h * .045 };
}
function setMini(on) {
if (on === S.mini) return;
const apply = () => {
S.mini = on;
root.classList.toggle('is-mini', on);
handle.setAttribute('aria-label', on ? 'Expand player' : 'Minimize player');
handle.setAttribute('aria-expanded', String(!on));
retarget();
};
clearTimeout(uiTimer);
if (S.phone && !reduced) {
slab.classList.add('is-hidden-ui');
uiTimer = setTimeout(() => { apply(); uiTimer = setTimeout(() => slab.classList.remove('is-hidden-ui'), 330); }, 170);
} else apply();
announce(on ? 'Player minimized' : 'Player expanded');
}
This is where the stage-1 trick pays off: the DOM jumps to the new box once, and a transform carries it from the old box while the springs settle, so the text is never re-laid-out per frame and is crisp when it lands.
const kx = bx.x - Math.round(G.x), ky = bx.y - Math.round(G.y), ks = bx.w / G.w, kt = bx.h / G.h;
if (Math.abs(kx) > .02 || Math.abs(ky) > .02 || Math.abs(ks - 1) > 1e-4 || Math.abs(kt - 1) > 1e-4) {
slab.style.transform = `translate(${kx.toFixed(2)}px, ${ky.toFixed(2)}px) scale(${ks.toFixed(5)}, ${kt.toFixed(5)})`;
if (!last.tf) { slab.style.willChange = 'transform'; last.tf = true; }
layoutDirty = true;
} else if (last.tf) { slab.style.transform = ''; slab.style.willChange = ''; last.tf = false; layoutDirty = true; }
On a phone the sheet becomes a glass bar at the bottom of the screen, and tapping the bar opens it again.
/* phone mini: a glass bar at the bottom */
.is-phone.is-mini .face { flex-direction: row; padding: 0 10px 0 12px; gap: 12px; }
.is-phone.is-mini .phead, .is-phone.is-mini .ctl-x, .is-phone.is-mini [data-act=prev], .is-phone.is-mini .kicker, .is-phone.is-mini .art-cap, .is-phone.is-mini .time { display: none; }
Shuffle and repeat are remembered, under versioned keys; anything older is wiped.
const KEY = 'dv52.v1.';
try { for (const k of Object.keys(localStorage)) if (k.startsWith('dv52') && !k.startsWith(KEY)) localStorage.removeItem(k); } catch (e) { /* storage off */ }
const store = {
get(k, d) { try { const v = localStorage.getItem(KEY + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch (e) { /* storage off */ } },
};

6 · The menu and the palette view
The ••• button opens a frosted menu: the queue of four albums, shuffle, repeat, the palette view and minimize, with arrow keys, Home, End and Escape, and focus returning to the button.
function openMenu() {
menu.hidden = false;
const r = menuBtn.getBoundingClientRect(), mw = menu.offsetWidth, mh = menu.offsetHeight;
let x = r.right - mw, y = r.bottom + 8;
if (y + mh > innerHeight - 8) y = Math.max(8, r.top - mh - 8);
x = clamp(x, 8, innerWidth - mw - 8);
menu.style.left = x + 'px'; menu.style.top = y + 'px';
menu.style.setProperty('--mox', `${r.left + r.width / 2 - x}px`); menu.style.setProperty('--moy', `${y > r.top ? 0 : mh}px`);
menuBtn.setAttribute('aria-expanded', 'true');
const cur = items().find((b) => b.getAttribute('aria-checked') === 'true' && b.classList.contains('qi')) || items()[0];
cur.focus({ preventScroll: true });
}
menu.addEventListener('keydown', (e) => {
const its = items(), i = its.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { its[(i + 1) % its.length].focus(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { its[(i - 1 + its.length) % its.length].focus(); e.preventDefault(); }
else if (e.key === 'Home') { its[0].focus(); e.preventDefault(); }
else if (e.key === 'End') { its[its.length - 1].focus(); e.preventDefault(); }
else if (e.key === 'Escape') { closeMenu(); e.preventDefault(); e.stopPropagation(); }
else if (e.key === 'Tab') { closeMenu(false); }
});
The palette view is the proof that the colours come from the album. The wallpaper is replaced by the album's four
measured colour bands (measured once by the image script, stored in manifest.js), and the slab empties.
function setPalette(on) {
if (on === S.palette) return;
if (on && S.mini) { S.mini = false; root.classList.remove('is-mini'); handle.setAttribute('aria-label', 'Minimize player'); handle.setAttribute('aria-expanded', 'true'); }
S.palette = on;
root.classList.toggle('is-palette', on);
slab.classList.toggle('is-hidden-ui', on);
face.inert = on; handle.inert = on;
pal.hidden = !on;
ui.t = on ? 0 : 1;
if (!on) { drag.x.t = 0; drag.y.t = 0; }
$('[data-pal-label]').textContent = on ? 'Back to player' : 'Palette view';
if (on) fillSwatches();
goScene(on ? 'bands' : 'wall');
function drawBands(cv, bands) { cv.width = 1024; cv.height = 8; const x = cv.getContext('2d'); bands.forEach((c, i) => { x.fillStyle = css(c); x.fillRect(i * 256, 0, 256, 8); }); return cv; }
Inside the empty glass, light caught by total internal reflection shows as dispersed diagonal streaks; drag the glass (or use the arrow keys) and watch the bands bend and split through it.
if (uPrism > .001) {
vec2 lp = (p - c) / b.y;
float s1 = exp(-pow((lp.x * .62 + lp.y - .35 + .25 * sin(uTime * .2)) / .05, 2.));
float s2 = exp(-pow((lp.x * -.9 + lp.y + .9) / .05, 2.)) * smoothstep(-2.2, .5, lp.x);
float s3 = exp(-pow((lp.x * .35 - lp.y - .62) / .06, 2.)) * smoothstep(.2, 1.8, lp.x);
vec3 sp1 = spectrum(lp.x * .35 + .1), sp2 = spectrum(lp.y * .6 + .5);
fc += uPrism * (s1 * (.16 + .22 * sp1) + s2 * (.1 + .2 * sp2) + s3 * .16 * spectrum(lp.x * .5));
fc = mix(fc, fc * 1.06 + .02, uPrism * .5);
}
slab.addEventListener('pointermove', (e) => {
if (e.pointerId !== dragId) return;
const vw = innerWidth, vh = innerHeight;
drag.x.t = clamp(e.clientX - dragFrom[0], -G.x - G.w * .5, vw - G.x - G.w * .5);
drag.y.t = clamp(e.clientY - dragFrom[1], -G.y - G.h * .4, vh - G.y - G.h * .6);
});
Measured with the project's frame probe on a 120 Hz screen through plays, scrubs, album changes, minimize and the palette view: one glass frame costs about 1.3 ms of GPU at 1440 × 900 and 0.4 ms on a 390 × 844 phone, with no long tasks.


Make it yours
- Your albums: put a 1:1 cover per album (and a wallpaper, if it has one) into
assets/incoming/and runscripts/build_images.py. It writes every size in WebP and JPEG and measures each cover's palette intomanifest.js: the four bands, the thread colours, the glass tint and the ink. The page code does not change. - Real audio: the clock is
S.t; drive it from an<audio>element'scurrentTimeand send seeks to it. - The glass: the magnification (1.075), the bevel and the wall depth are a few constants at the top of the
front-face and wall blocks in
glass.js. - Accessibility is already there: real buttons with labels, a slider with value text and the slider keys, a
menu with menu roles and roving focus, a polite live region for track changes, 44 px targets, and
prefers-reduced-motionhonoured (no sweep, no drift, springs snap, fades are instant).
The code is on GitHub at UlviShabanbayli/domvybe-liquid-player, under the MIT licence: use it in your own projects, commercial ones included. AURA SOUND and its artists are made up. The wallpaper and the album covers were generated with Google Gemini; the glass and everything else were made for this project.
Draw the material, not the text. The glass is one shader pass that reads the same box the DOM is laid out in, and every word stays DOM above it: the glass can bend light as much as it likes, and the type is still sharp, selectable and read out by a screen reader.