This is the whole build of the glass weather widget, in the order it was written: SkyGlass, the widget of a made-up weather brand, AURA weather. A pane of frosted glass sits over a golden-hour sky; every hour of the forecast changes the sky behind it and the weather on it. Rain lands on the glass and runs down it, each drop a tiny lens of the sky, and the Fog control fills the pane with condensation that you wipe away with the mouse or a finger. 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, three ES modules and three sky photos. The sky and the glass are one WebGL 2 shader drawn on a canvas behind the page; every word of the widget is real DOM text above it, so it stays sharp and readable.
1 · The widget in HTML, CSS and a little JavaScript
Start with the markup: the city and time, the condition, an icon, the temperature, and a strip of hours. The hours come from data, so the strip is an empty radio group the script fills.
<section class="widget" id="widget" aria-labelledby="w-city" data-fit>
<p class="city" id="w-city"><span class="name">NEO-PARIS</span> <time id="w-time">18:42</time></p>
<p class="cond" id="w-cond">Showers clearing</p>
<div class="w-icon" id="w-icon" aria-hidden="true"></div>
<div class="temp" aria-hidden="true"><span class="digits" id="digits"></span><span class="deg" id="w-unit">°F</span></div>
<p class="sr-only" id="w-temp-sr">72 degrees Fahrenheit</p>
<div class="strip" id="strip">
<div class="hours" id="hours" role="radiogroup" aria-label="Hourly forecast" tabindex="-1"></div>
<button class="handle" id="expandBtn" type="button" aria-expanded="false" aria-controls="hours">
<span class="sr-only">Show hourly details</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10l5 5 5-5"/></svg>
</button>
</div>
</section>
.widget {
position: relative; width: min(1100px, 62vw); min-height: clamp(400px, calc(100svh - 270px), 760px);
border-radius: var(--w-r); padding: 34px 40px 30px;
display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto 1fr auto;
grid-template-areas: "city icon" "cond icon" "temp temp" "strip strip";
touch-action: none; user-select: none; -webkit-user-select: none; cursor: default;
}
Until the WebGL glass arrives in stage 3, the pane is frosted in CSS over the photo, and the chosen hour gets a lit cell. The finished page keeps this as its fallback: a browser without WebGL 2 sees exactly this.
html:not(.gl) .widget {
background: linear-gradient(180deg, rgba(255, 246, 234, .26), rgba(255, 240, 225, .14));
-webkit-backdrop-filter: blur(22px) saturate(1.15); backdrop-filter: blur(22px) saturate(1.15);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7), inset 0 0 0 1px rgba(255, 255, 255, .35), 0 24px 60px rgba(50, 30, 15, .25);
}
html:not(.gl) .strip { background: rgba(80, 50, 40, .14); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35); }
html:not(.gl) .hour[aria-checked="true"] { background: rgba(255, 255, 255, .2); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .5); }
Each hour becomes a radio button with its time, a glossy SVG icon, and the details the handle opens later.
hoursEl.innerHTML = HOURS.map((h, i) => `
<button class="hour" type="button" role="radio" aria-checked="${i === 0}" tabindex="${i === 0 ? 0 : -1}" data-i="${i}">
<span class="h-t">${h.t}</span>
${icon(h.icon)}
<span class="more"><div>
<span class="h-temp">${toUnit(h.f)}°</span>
<span class="h-word">${h.word}</span>
<span class="pp" style="--p:${h.pp / 100}"><i></i></span>
<span class="h-pp">${h.pp}%</span>
</div></span>
</button>`).join('');
Picking an hour sets aria-checked, keeps only that cell in the tab order and rewrites the widget. Arrow keys, Home and End move through the hours, as the WAI-ARIA radio group pattern asks.
function applyHour(i, { instant = false, announce = false, focus = false } = {}) {
i = Math.max(0, Math.min(HOURS.length - 1, i));
const prev = state.sel; state.sel = i;
const h = HOURS[i];
cells.forEach((c, k) => { c.setAttribute('aria-checked', String(k === i)); c.tabIndex = k === i ? 0 : -1; });
if (focus) cells[i].focus({ preventScroll: true });
// keep the picked cell in view in the phone's scrolling strip
const sc = hoursEl; if (sc.scrollWidth > sc.clientWidth + 2) {
const c = cells[i]; const left = c.offsetLeft - sc.offsetLeft, right = left + c.offsetWidth;
if (left < sc.scrollLeft) sc.scrollTo({ left, behavior: instant ? 'auto' : 'smooth' });
else if (right > sc.scrollLeft + sc.clientWidth) sc.scrollTo({ left: right - sc.clientWidth, behavior: instant ? 'auto' : 'smooth' });
}
$('#w-time').textContent = h.clock;
$('#w-cond').textContent = h.cond;
// the icon: swap the symbol
const box = $('#w-icon'); const old = box.querySelector('.wx');
if (!old || old.dataset.name !== h.icon) { box.innerHTML = icon(h.icon); box.lastElementChild.dataset.name = h.icon; }
setTemp(toUnit(h.f));
if (announce && prev !== i) live.textContent = cellLabel(h);
}
hoursEl.addEventListener('keydown', (e) => {
const k = e.key; let i = state.sel;
if (k === 'ArrowRight' || k === 'ArrowDown') i++; else if (k === 'ArrowLeft' || k === 'ArrowUp') i--;
else if (k === 'Home') i = 0; else if (k === 'End') i = HOURS.length - 1; else return;
e.preventDefault(); i = (i + HOURS.length) % HOURS.length;
applyHour(i, { announce: true, focus: true });
});

2 · The widget moves
A temperature that jumps from 72 to 63 is a text change; one that rolls is a moment. Each digit becomes a reel of the digits 0 to 9, and the column itself narrows or widens to the digit it shows, measured from the font, so a 1 never leaves a gap.
const COLS = 3;
digitsEl.innerHTML = Array.from({ length: COLS }, () => `<span class="dcol"><span class="reel">${'0123456789'.split('').map((d) => `<span>${d}</span>`).join('')}</span></span>`).join('');
const dcols = [...digitsEl.querySelectorAll('.dcol')];
let digitW = null;
function measureDigits() {
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit';
$('.temp').appendChild(probe);
digitW = '0123456789'.split('').map((d) => { probe.textContent = d; return probe.getBoundingClientRect().width; });
const fs = parseFloat(getComputedStyle($('.temp')).fontSize);
digitW = digitW.map((w) => w / fs); // em
probe.remove();
}
function setTemp(v, instant = false) {
const s = String(v).padStart(COLS, ' ');
dcols.forEach((c, i) => {
const ch = s[i], reel = c.firstElementChild;
if (instant) { c.style.transition = reel.style.transition = 'none'; }
if (ch === ' ') { c.style.width = '0em'; }
else { const d = +ch; c.style.width = (digitW ? digitW[d] : 0.6) + 'em'; reel.style.transform = `translateY(${-d * 1.25}em)`; }
if (instant) { void c.offsetWidth; c.style.transition = reel.style.transition = ''; }
});
$('#w-unit').textContent = '°' + state.unit;
$('#w-temp-sr').textContent = `${v} degrees ${state.unit === 'F' ? 'Fahrenheit' : 'Celsius'}`;
}
.digits { display: inline-flex; height: 1.25em; overflow: hidden; margin: -.125em 0; }
.dcol { display: inline-block; height: 1.25em; overflow: hidden; transition: width .7s var(--ease); }
.reel { display: flex; flex-direction: column; transition: transform .9s var(--ease); will-change: transform; }
.reel span { display: block; height: 1.25em; line-height: 1.25em; text-align: center; }
The icon cross-fades: the new one scales in while the old one sinks away. With reduced motion everything changes at once.
// icon cross-fade
const box = $('#w-icon'); const old = box.querySelector('.wx:not(.out)');
if (!old || old.dataset.name !== h.icon) {
box.insertAdjacentHTML('beforeend', icon(h.icon)); const nu = box.lastElementChild; nu.dataset.name = h.icon;
if (old && !instant && !state.reduced) { nu.classList.add('out'); requestAnimationFrame(() => requestAnimationFrame(() => nu.classList.remove('out'))); old.classList.add('out'); setTimeout(() => old.remove(), 600); }
else if (old) old.remove();
}

3 · The sky and the frosted glass, in WebGL
A canvas arrives behind the page, and one fragment shader draws the whole picture. First the sky: the photo of the current hour, cover-fit with a slow drift and a little parallax from the mouse, cross-fading when the hour changes.
// cover-fit with a slow drift (zoom breathing) and pointer parallax
const z = this.reduced ? 1.03 : 1.035 + 0.012 * Math.sin(this.t * 2 * Math.PI / 52);
const asp = A.aspect;
let dw = Math.max(W, Hc * asp) * z, dh = dw / asp;
if (dh < Hc * z) { dh = Hc * z; dw = dh * asp; }
const f = W / Hc < 0.9 ? [0.23, 0.5] : A.focus; // portrait: keep the low sun in frame, lower left
const ox = (W - dw) * f[0] + this.parallax.x, oy = (Hc - dh) * f[1] + this.parallax.y;
gl.uniform4f(u.uSkyXf, 1 / dw, 1 / dh, -ox / dw, -oy / dh);
// ---- sky
vec3 sky = tS(suv(pc));
float sd = length(pc - uSun.xy);
float breath = 0.85 + 0.15 * sin(uT * 0.55);
sky += uSun.z * (exp(-sd / 70.0) * 0.22 * breath + exp(-sd / 380.0) * 0.07) * vec3(1.0, 0.82, 0.55);
Inside the widget's rounded rectangle the shader becomes glass. The rim is a bevel: near the edge the lookup is pulled inward, so the sky bends around it like the edge of a thick pane. The hourly strip is a second, inset pane.
vec2 gN = gRR(pc - wc, wb, uWR);
float e = clamp(1.0 + dW / 20.0, 0.0, 1.0);
float bev = e * e * e;
vec2 rp = pc - gN * bev * 34.0;
vec2 scn = uS.xy + uS.zw * 0.5, sb = uS.zw * 0.5;
float dS = sdRR(pc - scn, sb, uSR);
float inS = smoothstep(0.6, -0.6, dS);
float es = clamp(1.0 + dS / 9.0, 0.0, 1.0) * inS;
rp -= gRR(pc - scn, sb, uSR) * es * es * 7.0;
Frost is a blend between the sharp photo and a pre-blurred copy, with a little milk, the sun scattering warm light into it, and a slight greying so white type always reads on top.
vec2 uv = suv(rp);
// fetch only the layers this pixel needs (sharp where it is clear, blurred where it is frosted)
vec3 Bl = tB(uv);
float hb = smoothstep(0.0, 0.42, haze);
vec3 gcol = hb < 0.999 ? mix(tS(uv), Bl, hb) : Bl;
vec3 milk = mix(vec3(0.93, 0.905, 0.88), Bl * 1.05 + 0.12, mix(0.45, 0.22, smoothstep(0.5, 1.0, haze)));
// sunlight scattered in the frosted pane, warm near the sun
float lit = uSun.z * exp(-length(pc - uSun.xy) / (uCss.x * 0.24));
gcol += lit * (0.2 + 0.8 * haze) * vec3(0.20, 0.12, 0.04);
// frosted glass reads as a calm, slightly greyed pane (the board's beige), so white type holds on it
float fr = smoothstep(0.1, 0.45, haze);
float fogged = smoothstep(0.5, 1.0, haze);
gcol = mix(gcol, vec3(dot(gcol, vec3(0.299, 0.587, 0.114))), 0.2 * fr) * mix(1.0, 0.89, fr * (1.0 - fogged));
// condensation: a milky, low-contrast white veil
gcol = mix(gcol, milk, 0.05 + 0.1 * smoothstep(0.08, 0.42, haze) + 0.58 * fogged);
// the pane's rim: bright bevel line, top-lit, and a soft inner glow
float rimL = exp(-abs(dW + 0.9) / 0.85);
float top = 0.55 + 0.45 * -gN.y;
gcol += rimL * (0.32 + 0.38 * top);
gcol += bev * 0.07 * top;
gcol -= exp(-abs(dW + 3.0) / 2.2) * 0.05 * (1.0 - top);
// slow light drifting across the glass (idle motion)
float sw = dot(pc - uW.xy, normalize(vec2(1.0, 0.42)));
float sp0 = (fract(uT / 17.0) * 1.8 - 0.4) * (uW.z + uW.w * 0.42);
gcol += exp(-pow((sw - sp0) / 150.0, 2.0)) * 0.045 + exp(-pow((sw - sp0 - 120.0) / 22.0, 2.0)) * 0.025;
The JavaScript tells the shader where the pane, the strip and the chosen cell are, read from the DOM every frame, so the glass always sits exactly under the text.
function getRects() {
const w = widget.getBoundingClientRect(), s = strip.getBoundingClientRect();
const moving = stepLens();
if (glass) glass.rectsMoving = moving;
return {
w: { x: w.left, y: w.top, w: w.width, h: w.height }, wr: rCache.radius,
s: { x: s.left, y: s.top, w: s.width, h: s.height }, sr: rCache.stripR,
sel: { x: lens.l, y: lens.y, w: Math.max(0, lens.r - lens.l), h: lens.h }, selA: 1,
};
}

4 · Rain on the glass
Now the weather. Behind the pane, rain streaks fall through the sky in two depths.
float streaks(vec2 pc){
if (uStreak < 0.002) return 0.0;
vec2 q = vec2(pc.x - pc.y * 0.13, pc.y);
float a = 0.0;
for (int L = 0; L < 2; L++) {
float fl = float(L);
float w = mix(9.0, 5.0, fl);
float len = mix(190.0, 95.0, fl);
float cx = floor(q.x / w);
vec4 h = H(vec2(cx, 31.0 + fl), 3.0);
float y = q.y - uT * mix(1350.0, 900.0, fl) * (0.8 + 0.4 * h.y) - h.z * 7000.0;
float per = len * mix(3.6, 2.8, fl);
float k = floor(y / per);
vec4 g = H(vec2(cx, k), 5.0 + fl);
float f = (y - k * per) - g.w * (per - len);
float on = step(g.x, uStreak * mix(0.5, 0.42, fl));
float body = smoothstep(0.0, len, f) * step(f, len);
float xo = q.x - (cx + 0.25 + 0.5 * g.y) * w;
a += on * body * exp(-xo * xo / mix(1.1, 0.55, fl)) * mix(0.42, 0.28, fl) * (0.55 + 0.45 * g.z);
}
return a;
}
On the glass, three grids of beads land and dry: each cell holds at most one bead, hashed from its position, that pops in, holds, and shrinks away. The rain amount of the hour decides how many cells are wet.
vec3 bead(vec2 gp, float S, float s, float life, float eat, float dens){
vec2 id = floor(gp / S);
vec4 h = H(id, s);
vec4 k = H(id, s + 1.0);
float r0 = S * (0.08 + 0.28 * pow(h.z, 2.3));
vec2 c = (id + 0.5) * S + (h.xy - 0.5) * max(S - 2.8 * r0 - 2.0, 0.0); // room for the halo inside the cell
float P = 8.0 + 15.0 * k.x;
float ph = fract(uT / P + k.y);
float r = r0 * mix(1.0, smoothstep(0.0, 0.01, ph) * (1.0 - smoothstep(0.8, 1.0, ph)), life);
r *= smoothstep(k.z, k.z + 0.1, uRain * dens) * eat;
vec2 v = gp - c;
float il = inversesqrt(dot(v, v) + 1e-4);
float wob = 1.0 + 0.14 * (k.w - 0.5) * (2.0 * v.x * v.y * il * il) + 0.12 * (h.w - 0.5) * ((v.x * v.x - v.y * v.y) * il * il) + 0.07 * v.y * il;
return vec3(v / max(r * wob, 1e-3), r);
}
Running drops stick and slip down the pane: each column has one drop that moves in jerks, sways, and leaves a clear trail with a line of tiny beads behind it.
void runner(vec2 gp, float CW, float s, float span, inout vec3 best, inout float trail){
float off = s * 23.0;
float ci = floor((gp.x + off) / CW);
vec4 h = H(vec2(ci, 3.0), s);
vec4 g = H(vec2(ci, 4.0), s);
float exist = smoothstep(g.x, g.x + 0.12, uRain * 1.3 - 0.12);
if (exist <= 0.0) return;
float sp = 1.1 + 0.9 * h.z; // stick-slip beats per second
float P = 9.0 + 10.0 * h.y; // seconds per pass
float st = uT * sp + h.w * 50.0;
float fs = fract(st);
float jerk = floor(st) + smoothstep(0.3, 0.75, fs);
float mv = mix(st, jerk, 0.78) / (P * sp) + g.y;
float cyc = floor(mv);
float yd = fract(mv) * (span * 1.75 + 80.0) - 40.0;
vec4 cv = H(vec2(ci, cyc), s + 9.0);
float r = (4.2 + 5.6 * h.x) * exist * (0.8 + 0.4 * cv.x);
float base = (ci + 0.5) * CW - off;
float A = CW * 0.15, ph = cv.y * 6.2832;
float xd = base + A * sin(yd * 0.019 + ph) + A * 0.45 * sin(yd * 0.061 + ph * 1.7);
float slide = smoothstep(0.3, 0.45, fs) * (1.0 - smoothstep(0.6, 0.8, fs));
vec2 v = gp - vec2(xd, yd);
float ry = v.y < 0.0 ? r * (1.22 + 0.5 * slide) : r * 0.97;
vec2 d = vec2(v.x / (r * (1.0 - 0.07 * slide)), v.y / ry);
if (dot(d, d) < dot(best.xy, best.xy)) best = vec3(d, r);
float dy = yd - gp.y;
if (dy > 0.0) {
float xt = base + A * sin(gp.y * 0.019 + ph) + A * 0.45 * sin(gp.y * 0.061 + ph * 1.7);
float tw = r * 0.6 * (1.0 - 0.5 * smoothstep(0.0, 600.0, dy));
trail = max(trail, exp(-dy / 190.0) * smoothstep(tw, tw * 0.3, abs(gp.x - xt)));
float B = 9.0;
float kb = floor(gp.y / B);
vec4 b = H(vec2(ci + 977.0 * s, kb), s + 13.0);
float yb = (kb + 0.5) * B + (b.y - 0.5) * 3.0;
float xb = base + A * sin(yb * 0.019 + ph) + A * 0.45 * sin(yb * 0.061 + ph * 1.7) + (b.w - 0.5) * r * 0.8;
float dyb = yd - yb;
float rb = (0.8 + 2.1 * b.x * b.x) * step(b.z, 0.6) * smoothstep(r * 1.2, r * 2.2, dyb) * (1.0 - smoothstep(220.0, 640.0, dyb)) * exist;
vec2 db = (gp - vec2(xb, yb)) / max(rb, 1e-3);
if (rb > 0.05 && dot(db, db) < dot(best.xy, best.xy)) best = vec3(db, rb);
}
}
Every drop is a lens. It shows the sharp sky, inverted and minified, with a dark rim where light reflects inside the water, a glint from the top left and a bright caustic underneath.
// drops: lenses of the sharp sky
float dl = length(best.xy);
if (dl < 1.4 && best.z > 0.05) {
float r = best.z * uScale;
vec2 dd = best.xy;
vec2 c = pc - dd * r;
float hgt = sqrt(max(1.0 - dl * dl, 0.0));
vec3 n = normalize(vec3(dd * 0.95, hgt + 0.05));
vec2 q = c - dd * r * 5.5 - vec2(0.0, r * 1.5);
vec3 dc = tS(suv(q));
dc = dc * 1.06 + 0.035;
float rim = smoothstep(0.5, 1.0, dl);
dc *= 1.0 - 0.6 * rim * rim;
dc = mix(dc, dc * vec3(0.86, 0.9, 1.0), rim * 0.4);
vec3 R = reflect(vec3(0.0, 0.0, -1.0), n);
vec3 L = normalize(vec3(-0.45, -0.75, 0.5));
float spc = max(dot(R, L), 0.0);
dc += pow(spc, 70.0) * 1.5 + pow(spc, 9.0) * 0.07;
float caus = smoothstep(0.35, 0.85, dl) * smoothstep(0.1, 0.9, dd.y / max(dl, 1e-3)) * (1.0 - smoothstep(0.88, 1.0, dl));
dc += caus * 0.3 * vec3(1.0, 0.95, 0.85);
float cov = smoothstep(1.0, 1.0 - 1.6 / max(r * uDpr, 1.0), dl);
// outside the drop: a little shade above, focused light below
float ring = (1.0 - cov) * smoothstep(1.4, 1.0, dl);
gcol *= 1.0 - ring * 0.16 * smoothstep(0.2, -0.8, dd.y / dl);
gcol += ring * 0.12 * smoothstep(0.1, 0.9, dd.y / dl) * vec3(1.0, 0.96, 0.88);
gcol = mix(gcol, dc, cov);
}

5 · Fog you can wipe away
The Fog control raises the haze: it builds from the rim inward in soft, noisy patches until the pane is milky white, with fine condensation beads. Running drops still cut clear trails through it.
// haze: frost everywhere, fog builds from the edges in noisy patches, the wipe mask and running drops clear it
float m = texture(uMask, vec2(pc.x / uCss.x, 1.0 - pc.y / uCss.y)).r;
float clr = smoothstep(nz.r * 0.4, nz.r * 0.4 + 0.3, m);
float edgeF = 1.0 - smoothstep(-10.0, -230.0, dW); // 1 at the rim, 0 deep inside
float nf = texture(uNoise, pc / 1100.0 + vec2(0.37, 0.61)).g;
float fogP = smoothstep(0.0, 1.0, uFog * 1.25 - 0.12 + (nf - 0.5) * 0.6 + edgeF * 0.3 * uFog);
float haze = max(uFrost, fogP);
haze *= 1.0 - clr;
haze *= 1.0 - 0.9 * trail;
Wiping is a second, tiny render pass: a mask at a quarter of the CSS resolution, ping-ponged between two textures. Each pointer move paints a soft capsule into it, and the main shader reads it to clear the haze.
void main(){
vec2 f = gl_FragCoord.xy / uMRes;
float m = texture(uPrev, f).r - uDecay;
vec2 pc = vec2(f.x * uCss.x, (1.0 - f.y) * uCss.y);
vec2 a = uSeg.xy, b = uSeg.zw, pa = pc - a, ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-3), 0.0, 1.0);
float d = length(pa - ba * h);
m = max(m, uOn * smoothstep(uBrush, uBrush * 0.15, d));
o = vec4(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
}
The mask fades a little every frame, so the fog creeps back over the path, in patches, after a few seconds.
// fog creeps back: ~1/9 per second (held clear for the first ~3 s by the shader's threshold)
this.decayAcc += active ? dt * 0.105 : 0;
let decay = 0;
if (this.decayAcc >= 2 / 255) { decay = Math.floor(this.decayAcc * 255) / 255; this.decayAcc -= decay; }
The mouse wipes just by passing over the glass; on touch, a finger that starts on the pane wipes instead of scrolling, and the strip still scrolls sideways under a finger.
// ---------- wiping with the pointer ----------
let touchWiping = null;
widget.addEventListener('pointermove', (e) => {
if (!glass) return;
if (e.pointerType === 'mouse') { glass.wipe(e.clientX, e.clientY, { brush: 42 }); return; }
if (touchWiping === e.pointerId) glass.wipe(e.clientX, e.clientY, { brush: 36 });
});
widget.addEventListener('pointerdown', (e) => {
if (!glass || e.pointerType === 'mouse') return;
if (e.target.closest('.hours') && hoursEl.scrollWidth > hoursEl.clientWidth + 2) return; // let the strip scroll
touchWiping = e.pointerId; glass.endWipe(); glass.wipe(e.clientX, e.clientY, { brush: 36, fresh: true });
});
const endTouch = (e) => { if (touchWiping === e.pointerId) { touchWiping = null; glass && glass.endWipe(); } };
widget.addEventListener('pointerup', endTouch); widget.addEventListener('pointercancel', endTouch);
widget.addEventListener('pointerleave', () => glass && glass.endWipe());

6 · The finish
The selection lens glides between hours on two springs: the leading edge sets off first, so the lens stretches while it moves and settles softly on the new cell.
function stepLens() {
const now = performance.now(); const dt = Math.min(0.05, (now - lens.last) / 1000); lens.last = now;
const t = lensTarget();
if (!lens.init || state.reduced) { Object.assign(lens, { l: t.l, r: t.r, vl: 0, vr: 0, init: true }); lens.y = t.y; lens.h = t.h; return false; }
lens.y = t.y; lens.h = t.h;
const dir = Math.sign((t.l + t.r) / 2 - (lens.l + lens.r) / 2);
const kLead = 260, kTrail = 120;
const kl = dir < 0 ? kLead : kTrail, kr = dir > 0 ? kLead : kTrail;
const steps = Math.max(1, Math.ceil(dt / (1 / 240))), h = dt / steps;
for (let i = 0; i < steps; i++) {
lens.vl += (kl * (t.l - lens.l) - 2 * Math.sqrt(kl) * 0.82 * lens.vl) * h; lens.l += lens.vl * h;
lens.vr += (kr * (t.r - lens.r) - 2 * Math.sqrt(kr) * 0.82 * lens.vr) * h; lens.r += lens.vr * h;
}
const moving = Math.abs(t.l - lens.l) + Math.abs(t.r - lens.r) > 0.05 || Math.abs(lens.vl) + Math.abs(lens.vr) > 0.5;
if (!moving) { lens.l = t.l; lens.r = t.r; lens.vl = lens.vr = 0; }
return moving;
}
The header controls are glass too. A CSS backdrop blur over a canvas that changes every frame costs a blur pass per frame, so the shader frosts the pills itself and the DOM keeps only the sheen.
// frosted header pills: blurred photo, a little milk, a bright top rim
for (int i = 0; i < 3; i++) {
vec4 cr = uChip[i];
if (cr.z <= 0.0) continue;
vec2 cc = cr.xy + cr.zw * 0.5, cb = cr.zw * 0.5;
float dc = sdRR(pc - cc, cb, cb.y);
if (dc < 1.5) {
float cv = clamp(0.5 - dc * uDpr, 0.0, 1.0);
vec3 fc = tB(suv(pc)) * 0.92 + 0.07;
fc += exp(-abs(dc + 0.8) / 0.8) * (0.25 + 0.25 * -gRR(pc - cc, cb, cb.y).y);
col = mix(col, fc, cv);
}
}
When nobody touches anything, only the drops and the drifting fog move, and they do not need every frame of a 120 Hz screen: ambient frames render at half rate, while input, fades and glides always get every frame.
// ambient only (drops running, fog creeping back, nobody touching anything): the canvas updates every other
// display frame, which halves the GPU work on 120 Hz screens; input, fades and glides always get every frame
const ambient = !busy && !this.reduced;
this._odd = ambient ? !this._odd : false;
this._acc = (this._acc || 0) + dt;
if ((moving || maskActive || busy) && !this._odd) {
if (this.forceFrames > 0) this.forceFrames--;
this._draw(now, this._acc, maskActive);
this._acc = 0;
this.dirty = false;
}
Measured in the page, a frame of the sky and the glass costs the GPU about 2 ms at 1440 × 900 and under 2 ms on a phone.


Make it yours
- Your forecast is the HOURS list in
app.js: each hour has its text and icon, and the look of the glass: which sky, how much rain on the pane, how hard it falls behind it, the fog and the warmth. - Your skies are three photos per condition (sharp, blurred, heavily blurred for the fog), wide enough to
cover the screen; swap them in
SKIES. - Accessibility is already there: a real radio group with roving tabindex, buttons with state for fog, wipe,
details and units, a live region, a visible focus ring, 44 px targets,
prefers-reduced-motionhonoured, and a CSS frosted fallback without WebGL 2.
The code is on GitHub at UlviShabanbayli/domvybe-glass-weather, under the MIT licence: use it in your own projects, commercial ones included. AURA weather and Neo-Paris are made up. The three sky photos are AI-generated (Google Gemini); everything else was made in code for this project.
Read every rectangle from the DOM each frame and let the shader draw the glass under them. The text stays real, sharp and accessible, and the glass, the drops and the fog always sit exactly where the layout puts them, at any size.