Library / Tutorial

Liquid glass player,
step by step.

A music player made of thick liquid glass: it refracts and magnifies the album's wallpaper, the play button is a glass puck, the progress bar is a liquid thread you can drag, and every album re-tints the glass. Six stages, each a live page you can run.

6Stages
1Shader pass
0Libraries

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.

index.html — the wallpaper
<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.

style.css — the slab
.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)); }
style.css — the album art, sized in --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.

app.js — where the slab goes
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.

app.js — springs
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) };
app.js — the target box, written once
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.

app.js — the walls lean
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.

style.css — the frost fallback
.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); }
Stage 1 · the slab in CSS frostOpen ↗
The AURA SOUND player over an orchid sunset photo: a frosted glass card with the Nebula Dreams album cover, the title, the controls and a plain progress bar, its thickness drawn as a pale offset edge.

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.

app.js — the glass, or the 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.

app.js — the wallpaper as a texture
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);
glass.js — the same cover fit as the CSS
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.

glass.js — the rounded box
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.

glass.js — normals, refraction, magnification
// ---------------- 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.

glass.js — a chromatic split
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.

glass.js — the walls
// ---------------- 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.

app.js — where the DOM controls are
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;
}
Stage 2 · real glassOpen ↗
The same player as thick clear glass: the orchids bend and magnify through its right end, its walls glow with prism colours, the play button is a frosted glass puck and the progress bar a glowing violet thread.

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.

app.js — the clock
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.

app.js — play and pause
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');
}
app.js — the ripple, handed to the shader
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.

app.js — scrubbing
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);
app.js — the slider keys
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.

glass.js — the liquid thread
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);
Stage 3 · play and scrubOpen ↗
The player while its progress bar is being dragged: the glowing thread stretched to the glass knob near the end, the times shown under it.

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.

app.js — change the album
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.

app.js — the scene cross-fade
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.

app.js — one curve for both
// 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();
}
glass.js — the shader mixes the two photos
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);
}
style.css — the titles step out and back
.meta.is-out > * { opacity: 0; transform: translateY(calc(-2 * var(--u))); }
.meta.is-out .artist { transform: translateY(calc(2 * var(--u))); }
Stage 4 · a new albumOpen ↗
The player on Last Light: the wallpaper is now a warm orange and teal blur of the palm-tree cover, and the glass, the play button and the thread carry its colours.

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.

app.js — hover and focus
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; }
style.css — the extra controls
.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.

app.js — the minimized box
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 };
}
app.js — minimize
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.

app.js — the morph
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.

style.css — the mini bar
/* 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.

app.js — storage that never throws
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 */ } },
};
Stage 5 · hoverOpen ↗
The player with the pointer over it: the glass lifted, shuffle and repeat beside the controls, and the times under the thread.

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.

app.js — open the menu
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 });
}
app.js — menu keys
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.

app.js — the palette view
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');
app.js — the bands as a texture
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.

glass.js — light caught inside the slab
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);
}
app.js — drag the glass
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.

Stage 6 · the finished playerOpen ↗
The palette view: four vertical colour bands from the Nebula Dreams cover, dark navy, coral, violet and crimson, seen through the empty glass slab with light streaks inside it, and a caption with the four hex values and a Back to player button.
Stage 6 · on a phoneOpen ↗
The player on a phone: a full-height glass sheet over the orchid sunset, the large Nebula Dreams cover, the title, the liquid thread with its times, and shuffle, previous, the glass play button, next and repeat.

Make it yours

  • Your albums: put a 1:1 cover per album (and a wallpaper, if it has one) into assets/incoming/ and run scripts/build_images.py. It writes every size in WebP and JPEG and measures each cover's palette into manifest.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's currentTime and 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-motion honoured (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.

The one idea worth keeping

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.