Library / Tutorial

Glass command palette,
step by step.

A Spotlight-style search bar made of liquid glass. Cmd/Ctrl+K pours it open, typing grows it into grouped results, and the selected row is a capsule of glass that glides between them. Four stages, each a live page you can run.

4Stages
1WebGL shader
0Libraries

This is the whole build of the glass command palette, in the order it was written. Press Cmd/Ctrl+K and a bar of thick glass pours open over a silk-aurora desktop; type, and it grows into grouped results; the selected row is a glowing glass capsule that glides, stretches and settles. 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.

The rule that holds it together: the words are DOM and the glass is WebGL. Every row is a real option in a listbox that a screen reader and the keyboard understand; one canvas under it draws the glass from the rectangles the DOM reports. There is no framework, no library and no build step: one HTML page, one style sheet and four ES modules.

1 · The combobox, in the DOM

The palette is a modal dialog holding a combobox: the input owns the list (aria-controls), says which option is selected (aria-activedescendant) and whether the list is open (aria-expanded).

index.html — the dialog and the combobox
<div id="pal" class="pal" role="dialog" aria-modal="true" aria-label="Command palette">
  <div class="pal-panel">
    <div class="pal-field">
      <svg class="ic pal-search-ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="6.3"/><path d="m15.6 15.6 4.6 4.6"/></svg>
      <input id="pal-input" class="pal-input" type="text" role="combobox" aria-expanded="true" aria-controls="pal-list" aria-autocomplete="list"
             aria-haspopup="listbox" aria-label="Find or do anything" aria-describedby="pal-help" placeholder="Find or do anything..." autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="go">

The search is fuzzy in three tiers: a substring scores highest (more if it starts a word), then the first letters of words ("nn" finds New Note), then a loose match that must start on a word. It returns the matched letters so the row can light them up.

app.js — fuzzy match
export function fuzzy(q, text) {
  q = q.trim().toLowerCase(); if (!q || !text) return null;
  const t = text.toLowerCase();
  const ws = (i) => i === 0 || /[\s/·.,\-–—_(]/.test(t[i - 1]);
  let idx = t.indexOf(q);
  if (q.length === 1) { idx = -1; for (let i = t.indexOf(q); i >= 0; i = t.indexOf(q, i + 1)) if (ws(i)) { idx = i; break; } if (idx < 0) return null; }
  if (idx >= 0) {
    // prefer an occurrence on a word start
    let best = idx; for (let i = idx; i >= 0 && i < t.length; i = t.indexOf(q, i + 1)) if (ws(i)) { best = i; break; }
    const hits = []; for (let i = 0; i < q.length; i++) hits.push(best + i);
    return { score: 100 + (ws(best) ? 30 : 0) + (best === 0 ? 25 : 0) + q.length * 3 - t.length * 0.15, hits };
  }
  const qs = q.replace(/\s+/g, '');
  // word-start acronym, allowing runs inside a word
  let qi = 0, run = false; const hits = [];
  for (let i = 0; i < t.length && qi < qs.length; i++) {
    if (t[i] === qs[qi] && (ws(i) || run)) { hits.push(i); qi++; run = true; } else if (t[i] === ' ' || ws(i)) run = false; else run = run && false;
  }
  if (qi === qs.length) return { score: 60 + hits.length * 4 - t.length * 0.15, hits };
  // loose: first letter on a word start, the rest in order with limited gaps
  const first = [...t].findIndex((c, i) => c === qs[0] && ws(i)); if (first < 0) return null;
  const h2 = [first]; let gaps = 0;
  for (let i = first + 1, k = 1; k < qs.length; i++) {
    if (i >= t.length) return null;
    if (t[i] === qs[k]) { h2.push(i); k++; } else gaps++;
  }
  if (gaps > Math.max(4, qs.length * 2)) return null;
  return { score: 30 - gaps * 2 + qs.length * 2 - t.length * 0.1, hits: h2 };
}

On a wide screen a broad query splits its groups into two balanced columns, filled so that reading order is arrow order: down the left column, then the right.

app.js — two balanced columns
// two balanced columns of groups on wide screens (reading order = arrow order: down the left column, then the right)
let cols = [res.groups];
if (wide) {
  const w = (g) => g.rows.length + 0.6; const half = res.groups.reduce((n, g) => n + w(g), 0) / 2;
  let acc = 0, cut = 1;
  for (let i = 0; i < res.groups.length - 1; i++) { acc += w(res.groups[i]); cut = i + 1; if (acc >= half) break; }
  cols = [res.groups.slice(0, cut), res.groups.slice(cut)];
}

The keys are the combobox pattern: arrows move and wrap, PageUp/PageDown jump five, Ctrl/Cmd+Home/End go to the ends, Enter runs, and Escape clears the query before it closes.

app.js — the keyboard
E.input.addEventListener('keydown', (e) => {
  if (e.isComposing) return;
  const n = S.rows.length;
  if (e.key === 'ArrowDown') { e.preventDefault(); move(S.sel + 1); }
  else if (e.key === 'ArrowUp') { e.preventDefault(); move(S.sel - 1); }
  else if (e.key === 'PageDown') { e.preventDefault(); if (n) move(Math.min(n - 1, S.sel + 5)); }
  else if (e.key === 'PageUp') { e.preventDefault(); if (n) move(Math.max(0, S.sel - 5)); }
  else if ((e.key === 'Home' || e.key === 'End') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (n) move(e.key === 'Home' ? 0 : n - 1); }
  else if (e.key === 'Enter') { e.preventDefault(); run(S.sel); }
  else if (e.key === 'Escape') {
    e.preventDefault(); e.stopPropagation();
    if (S.q) { E.input.value = ''; S.q = ''; render(); layout(); } else close();
  }
});

Opening makes everything behind the dialog inert, and closing gives focus back to the trigger. Cmd/Ctrl+K (or /) toggles it from anywhere.

app.js — open and close
function setBackgroundInert(on) {
  for (const el of E.bg) el.inert = on;
  E.trigger.inert = on; E.pal.inert = !on;
  E.trigger.setAttribute('aria-expanded', on ? 'true' : 'false');
  E.input.setAttribute('aria-expanded', on ? 'true' : 'false');
}

function open(q = '') {
  if (S.open) return;
  S.returnTo = document.activeElement;
  S.open = true; S.q = q; E.input.value = q;
  root.classList.add('is-open'); E.pal.classList.add('is-shown'); S.pouring = !reduced();
  setBackgroundInert(true);
  render(); layout();
  E.input.focus({ preventScroll: true });
}

function close({ restore = true } = {}) {
  if (!S.open) return;
  S.open = false;
  root.classList.remove('is-open'); E.pal.classList.remove('is-shown');
  setBackgroundInert(false);
  paintSel();
  layout();
  if (restore) E.trigger.focus({ preventScroll: true });
}
app.js — Cmd/Ctrl+K
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); S.open ? close() : open(); return; }
if (!S.open && e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); open(); }

For now the glass is CSS: a blurred, saturated backdrop and a few inset highlights. It stays as the fallback when WebGL is not there.

style.css — the frosted CSS palette
html:not(.gl-on) .trigger, html:not(.gl-on) .pal-panel, html:not(.gl-on) .cap {
  -webkit-backdrop-filter: blur(26px) saturate(165%) brightness(1.08); backdrop-filter: blur(26px) saturate(165%) brightness(1.08); }
html:not(.gl-on) .trigger, html:not(.gl-on) .pal-panel {
  background: linear-gradient(180deg, rgba(255, 255, 255, .5), rgba(255, 255, 255, .36));
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .6), inset 0 1px 0 rgba(255, 255, 255, .9), inset 0 -1px 0 rgba(150, 240, 255, .7), 0 20px 50px rgba(20, 12, 60, .3); }
Stage 1 · the combobox in the DOMOpen ↗
The palette in frosted CSS over the silk-aurora wallpaper:

2 · The glass

One canvas goes under the palette. If WebGL starts, the page switches from the CSS frost to the glass.

app.js — the glass, or the CSS frost
try { glass = new Glass(E.canvas, { onFrame: onGlassFrame }); } catch (e) { glass = null; }
if (!glass || glass.failed) { glass = null; root.classList.add('no-gl'); }

Everything in the glass is a signed distance: how far a pixel is from the edge of a rounded rectangle. The palette is one, the field another, the capsule a third; two bodies can be melted together with a smooth minimum.

glass.js — rounded rectangles as distances
float sdR(vec2 p, vec4 r, float rad){
  vec2 c=(r.xy+r.zw)*0.5; vec2 b=max((r.zw-r.xy)*0.5, vec2(0.5));
  rad=min(rad, min(b.x,b.y));
  vec2 q=abs(p-c)-b+rad;
  return length(max(q,0.0))+min(max(q.x,q.y),0.0)-rad;
}
float smin(float a,float b,float k){ float h=max(k-abs(a-b),0.0)/k; return min(a,b)-h*h*k*0.25; }
float slab(vec2 p){ float a=sdR(p,uA,uAR); if(uBOn>0.5) a=smin(a, sdR(p,uB,uBR), uK); return a; }

Near the edge the distance turns into a bevel: a surface normal that tilts more the closer the pixel is to the rim. That normal is what makes it read as thick glass.

glass.js — a bevel from a distance
vec3 bevelN(float d, vec2 g, float bw, float k){
  float s=1.0-clamp(-d/bw,0.0,1.0);
  float slope=min(s/sqrt(max(1.0-s*s,0.02)), 7.0);
  return normalize(vec3(g*slope*k,1.0));
}

The middle of the slab shows a frosted copy of the wallpaper; the bevel refracts the sharp wallpaper from just outside the edge, a little more for blue than for red, so the rim splits light like a prism.

glass.js — frost in the middle, refraction at the rim
vec3 n=bevelN(d,g,24.0*D,0.62);
float rim=1.0-n.z;
vec2 off=g*rim*88.0*D;
float rimMix=smoothstep(0.03,0.42,rim);
vec3 c=frost(p);
if(rimMix>0.0){
  vec3 s3=vec3(sharp(p+off*0.9).r, sharp(p+off).g, sharp(p+off*1.12).b);
  c=mix(c, s3, rimMix*0.92);
}

The frost is not a blur per frame: the wallpaper is blurred once, on the CPU, into a small texture when it loads.

glass.js — the wallpaper, sharp and frosted
// the sharp wallpaper as the page shows it, plus a frosted copy (box blur ×3 ≈ gaussian, ~18 css px at 1440 wide)
setWallpaper(img) {
  this._img = img;
  const gl = this.gl; if (!gl) return;
  gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.texS);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
  const w = 400, h = Math.round(w * img.naturalHeight / img.naturalWidth);
  const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
  const cx = cv.getContext('2d', { willReadFrequently: true });
  cx.drawImage(img, 0, 0, w, h);
  const id = cx.getImageData(0, 0, w, h);
  blur(id.data, w, h, 4); blur(id.data, w, h, 4); blur(id.data, w, h, 4);
  gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.texB);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(id.data.buffer));
  this.ready = true; this.fade = 1;
  this.kick();
}

Each frame the page tells the glass where the palette, the field and the selected row are, and every edge springs to its target on its own. When it opens the bottom edge leads, so the glass pours down out of the pill.

app.js — rectangles for the glass
let A, B;
if (S.open) {
  if (phone) { A = { ...targets.field, r: 18 }; B = { ...targets.body, r: 22 }; }
  else A = { ...targets.panel, r: metrics().rPanel };
} else {
  const t = rectOf(E.trigger); A = { ...t, r: phone ? 18 : (t.y1 - t.y0) / 2 };
  if (phone) B = { ...A, y0: A.y0 + 6, y1: A.y1 - 6, x0: A.x0 + 10, x1: A.x1 - 10 };
}
// the row's layout box (not its transformed box: rows fade up into place, the capsule must aim at where they land)
const el = cur && cur.el, cap = S.open && el && !E.list.hidden
  ? { x0: list.x0 + el.offsetLeft, y0: list.y0 + el.offsetTop - E.list.scrollTop, x1: list.x0 + el.offsetLeft + el.offsetWidth, y1: list.y0 + el.offsetTop - E.list.scrollTop + el.offsetHeight } : null;
glass.set({ A, B, bOn: phone, cap, capInstant, clip: list, open: S.open, mode });

The capsule is a liquid body: a head that springs quickly to the new row and overshoots, a tail that chases the head (not the target), and a neck between them that thins as it stretches. Across columns it pours diagonally.

glass.js — the tail chases the head
step(h) {
  // the tail chases the head, not the target: that is what makes the drop stretch and then catch up
  this.tx.t = this.hx.v; this.ty.t = this.hy.v;
  for (const s of [this.hx, this.hy, this.tx, this.ty, this.hw, this.hh]) s.step(h);
}
glass.js — head, tail and neck, melted together
// the capsule is a liquid body: the head pill, the tail pill and an oriented neck between them, smooth-unioned
float capS(vec2 p){
  float k=uCR*0.9;
  float s=smin(sdR(p,uH,uCR), sdR(p,uTl,uCR*0.92), k);
  if(uNk.z>0.25){
    // neck: a thinner copy of the pill slid along the path (the slice nearest p), so the body sweeps like poured glass
    vec2 T=(uTl.xy+uTl.zw)*0.5, H=(uH.xy+uH.zw)*0.5, Dv=H-T;
    float t=clamp(dot(p-T,Dv)/max(dot(Dv,Dv),1.0),0.0,1.0);
    vec2 c=mix(T,H,t);
    vec2 hb=mix((uTl.zw-uTl.xy)*0.5, (uH.zw-uH.xy)*0.5, t)*(1.0-uNk.w*4.0*t*(1.0-t));
    s=min(s, sdR(p, vec4(c-hb, c+hb), uCR));
  }
  return max(s, sdR(p,uClip,0.0));
}

The words never go through the shader. While the glass pours, the labels wait until it has nearly reached its size, so no word ever sits outside a moving edge.

app.js — the labels wait for the glass
// opening: the labels only appear once the glass has (nearly) reached its size, so no word ever sits outside a moving
// edge; only opacity changes per frame (composited), nothing is re-laid-out or repainted
const fadeEl = targets.phone ? E.body : E.panel, base = targets.phone ? targets.body : targets.panel, r = targets.phone ? st.B : st.A;
// (the per-frame value is the fade, so the element's own CSS opacity transition is switched off meanwhile: otherwise
// every write restarts its delayed transition and the card stays empty until the pour has ended)
if (done || !r || !S.open || S.motion !== 'open') { fadeEl.style.opacity = ''; fadeEl.style.willChange = ''; fadeEl.style.transition = ''; }
else {
  const pw = (r.x1 - r.x0) / (base.x1 - base.x0), ph = (r.y1 - r.y0) / (base.y1 - base.y0);
  const dy = Math.abs(r.y0 - base.y0) + Math.abs(r.y1 - base.y1);
  fadeEl.style.willChange = 'opacity'; fadeEl.style.transition = 'none';
  fadeEl.style.opacity = Math.max(0, Math.min(1, (Math.min(pw, ph) - 0.86) / 0.12, 1 - dy / 60)).toFixed(3);
}
Stage 2 · the glassOpen ↗
The palette as thick liquid glass over the aurora wallpaper: a frosted field with FIND OR DO ANYTHING..., five suggestions and a glowing capsule with an iridescent edge on Go to Website.

3 · It does things

The five suggestions open scopes. The scope shows as a chip in the field, and Backspace or Escape leaves it, with the capsule gliding back to the suggestion it came from.

app.js — enter a scope
function enterScope(id) {
  S.scope = id; S.q = ''; E.input.value = ''; showScope();
  render(); layout();
  E.input.focus({ preventScroll: true });
}
app.js — leave it
function leaveScope() {
  const was = S.scope; S.scope = null; S.q = ''; E.input.value = ''; showScope();
  render(); S.sel = Math.max(0, SUGGESTIONS.findIndex((s) => s.scope === was)); paintSel(); layout();
  E.input.focus({ preventScroll: true });
}

Enter runs a command: the capsule flashes, the glass drains back into the pill and a small glass toast says what happened. Three commands change the page for real.

app.js — run
function run(i) {
  const it = S.rows[i]; if (!it) return;
  const r = it.row;
  S.sel = i; paintSel(); layout();
  glass && glass.pulse();
  if (r.scope) { enterScope(r.scope); return; }
  if (r.toggle) {
    if (r.toggle === 'theme') { S.theme = S.theme === 'smoke' ? 'frost' : 'smoke'; store.set('theme', S.theme); look(); E.announce.textContent = `${S.theme === 'smoke' ? 'Smoke' : 'Frost'} glass`; }
    if (r.toggle === 'dnd') { S.dnd = !S.dnd; toast(`Do Not Disturb ${S.dnd ? 'on' : 'off'}`, icon('moon')); }
    if (r.toggle === 'motion') { S.motionOff = !S.motionOff; store.set('motion', S.motionOff ? 'off' : 'on'); applyMotion(); toast(`Reduce Motion ${reduced() ? 'on' : 'off'}`, icon('wave')); }
    const keep = S.sel; render({ keepSel: true }); S.sel = keep; paintSel(); layout();
    return;
  }
  const ic = r.app ? appTile(r.app[0], r.app[1], r.glyph) : icon(r.icon);
  const msg = r.web ? `Searching the web for “${r.web}”`
    : { apps: `Opening ${r.label}`, docs: `Opening “${r.label}”`, folders: `Opening ${r.label}`, web: `Going to ${r.label}`, marks: `Opening “${r.label}”`, settings: `Opening ${r.label} settings` }[r.g]
    || { 'x-note': 'New note created', 'x-window': 'New window opened', 'x-focus': 'Focus timer started · 25:00', 'x-shot': 'Screenshot saved to Desktop', 'x-lock': 'Screen locked (demo)' }[r.id] || r.label;
  setTimeout(() => { close(); toast(msg, ic); }, reduced() ? 0 : 140);
}
style.css — a glass toast
.toast { display: inline-flex; align-items: center; gap: 10px; padding: 11px 18px 11px 12px; border-radius: 18px; font-size: 14.5px; font-weight: 500; color: var(--ink);
  background: rgba(255, 255, 255, .44); -webkit-backdrop-filter: blur(22px) saturate(170%); backdrop-filter: blur(22px) saturate(170%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .62), inset 0 1px 0 rgba(255, 255, 255, .8), 0 14px 34px rgba(20, 14, 60, .28);
  animation: toastin .42s var(--ease) both; }

Smoke Glass swaps the tint of the same shader for a dark one. It is remembered, and it never follows the OS.

app.js — Frost or Smoke
function look() {
  const smoke = S.theme === 'smoke', solid = mqTransp.matches;
  root.dataset.theme = smoke ? 'smoke' : 'frost';
  glass && glass.setLook(smoke ? { tint: [0.055, 0.06, 0.1], tintAmt: solid ? 0.86 : 0.56, dark: 1 } : { tint: [1, 1, 1], tintAmt: solid ? 0.8 : 0.39, dark: 0 });
}
Stage 3 · scopes and commandsOpen ↗
The Applications scope: a chip in the field and eleven apps with glossy tiles, the capsule on Notes.

4 · The frame budget

Last, it has to stay smooth at 120 Hz and on a slow phone. Typing renders once per frame, however fast the keys come.

app.js — one render per frame
// results are rebuilt in the next frame's rAF (one per frame however fast the typing): DOM changes, layout and paint then
// land in a single frame instead of a forced layout in the input task plus a second pass in the frame
let typeRaf = 0;
E.input.addEventListener('input', () => {
  S.q = E.input.value;
  if (!typeRaf) typeRaf = requestAnimationFrame(() => { typeRaf = 0; render(); layout(); });
});

Every row is built and laid out once while the page is idle, so a keystroke never parses an icon or shapes text for the first time.

app.js — warm up at idle
// build every option node once while the page is idle, so typing never parses an icon
const idle = window.requestIdleCallback || ((f) => setTimeout(() => f({ timeRemaining: () => 8 }), 200));
// …and lay them out once in an invisible staging list, so their text is already shaped when they are first shown
const stage = document.createElement('div'); stage.className = 'pal-stage'; stage.setAttribute('aria-hidden', 'true'); E.body.append(stage);
for (const id of Object.keys(GROUPS)) { const g = groupEl({ id }); if (!g.parentNode) stage.append(g); }
let k = 0; const warm = (dl) => {
  const batch = [];
  while (k < ITEMS.length && dl.timeRemaining() > 2) { const o = optionEl(ITEMS[k++]); if (!o.parentNode) batch.push(o); }
  stage.append(...batch); void stage.offsetHeight;
  if (k < ITEMS.length) idle(warm);
  else {
    // paint every icon once (nearly transparent, for two frames) so no SVG image is parsed during a keystroke
    const strip = document.createElement('div'); strip.className = 'pal-warm'; strip.setAttribute('aria-hidden', 'true');
    strip.innerHTML = [...new Set(ITEMS.map((r) => (r.app ? appTile(r.app[0], r.app[1], r.glyph) : icon(r.icon))))].join('') + icon('search');
    document.body.append(strip);
    requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => {
      strip.remove(); stage.remove(); })));
  }
};
idle(warm);

The shader is drawn only around the glass, and the loop sleeps: springs step only while something moves, and at rest only the slow light drifting across the glass is drawn.

glass.js — only around the glass
const x0 = Math.min(a.x0, b.x0) - 90, x1 = Math.max(a.x1, b.x1) + 90, y0 = Math.min(a.y0, b.y0) - 70, y1 = Math.max(a.y1, b.y1) + 120;
const sx = clamp(Math.floor(x0 * D), 0, W), sy = clamp(Math.floor((this.vh - y1) * D), 0, H);
gl.scissor(sx, sy, clamp(Math.ceil((x1 - x0) * D), 0, W - sx), clamp(Math.ceil((y1 - y0) * D), 0, H - sy));
glass.js — the loop
_tick(now) {
  if (!this.running) return;
  const dt = clamp((now - this.last) / 1000, 0, 1 / 30); this.last = now;
  if (!this.frozen) {
    const moving = !this.settled;
    if (moving) {
      const sub = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / sub;
      for (let i = 0; i < sub; i++) { this.A.step(h); if (this.bOn) this.B.step(h); this.C.step(h); this.open.step(h); this.capOn.step(h); }
      this.flash *= Math.exp(-dt * 7);
    }
    const idle = this.idle && !this.reduced;
    if (idle) this.time += dt;
    this.draw();
    if (moving && this.onFrame) this.onFrame(this);
    if (!moving && !idle) { this.running = false; if (this.onFrame) this.onFrame(this); return; }
  }
  requestAnimationFrame(this._tick);
}

Measured with the project's frame probe at 120 Hz through glides, typing, the two-column morph, scopes and the pour: no missed frames and no long tasks at 1440 × 900 or on a 390 × 844 phone. The shader costs about half a millisecond of GPU time per frame on the widest palette.

Stage 4 · the finished paletteOpen ↗
The finished palette mid-glide:
Stage 4 · on a phoneOpen ↗
The palette on a phone: a full-width top sheet, the frosted field and a separate glass card of suggestions with the capsule on Go to Website.

Make it yours

  • What it finds is ITEMS in data.js: a label, a group, an icon and optional keywords, meta text or a shortcut.
  • Your wallpaper: replace the images in assets/; the glass frosts and refracts whatever is there.
  • The look of the glass is a tint and an amount in look(), and the rim, prism and light are a few lines each in the shader.
  • No WebGL: ?nogl shows the frosted CSS palette; search, scopes, commands and the keyboard all still work.
  • Accessibility is already there: a modal dialog, a combobox with a grouped listbox, a polite live count, everything behind it inert, Tab kept inside, focus returned, 44 px rows, prefers-reduced-motion and prefers-reduced-transparency honoured.

The code is on GitHub at UlviShabanbayli/domvybe-glass-palette, under the MIT licence: use it in your own projects, commercial ones included. Aether and everything it finds are made up. The glass is WebGL made in code; the wallpaper is AI-generated (Google Gemini).

The one idea worth keeping

Let the DOM say where things are and let the GPU decide how they look. The words stay sharp and accessible; the glass can pour, stretch and refract.