This is the whole build of the frost notification center, in the order it was written. Nine notifications sit in a stack of thick ice slabs on a winter night: point at the stack and it fans out, point at a card and it lifts towards you, swipe one away and it shatters into frost. 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 one rule that holds it together: the text is DOM and the ice is WebGL, and both are placed by the same
matrix. Every card is a real <article> you can read, focus and select, moved in 3D with
matrix3d; the ice is drawn under it by a camera built to match the CSS perspective exactly. There is no
framework and no build step: one HTML page, one style sheet, three ES modules and three.js r186.
1 · The stack, in the DOM
Each notification is an <article> inside a list item, labelled by its title and described by its
sender and body, so a screen reader hears a list of notifications, not a pile of shapes.
li.innerHTML = `<article class="card" tabindex="-1" aria-labelledby="n${id}t" aria-describedby="n${id}w n${id}b">
<div class="card-in">
<header class="card-h"><span class="ic">${ICONS[d.app]}</span><h3 class="card-t" id="n${id}t"><span class="sr-only unread-sr">Unread. </span></h3>
<time class="card-time"></time><i class="dot" aria-hidden="true"></i></header>
<p class="card-who" id="n${id}w"></p>
<p class="card-b" id="n${id}b"></p>
</div>
</article>`;
The cards live under one CSS perspective, centred on the stack. The rest pose is the concept's hero: four readable cards cascade down to the right, each newer one lower and nearer, and the older ones fan out behind like book pages.
u = W / 420; PERSP = phone ? 1100 : 1500;
html.style.setProperty('--card-w', W + 'px'); html.style.setProperty('--card-h', H + 'px'); html.style.setProperty('--u', u);
list.style.perspective = PERSP + 'px';
if (!expanded) {
const dx = 28 * u, dy = 72 * u, dz = 30;
for (let k = 0; k < n; k++) {
const p = zeroPose();
if (k < F4) { p.x = -k * dx; p.y = k * dy; p.z = -k * dz; p.ry = 19 + k * 1.5; p.rx = -8; }
else { const j = k - F4 + 1; p.x = -(F4 - 1) * dx - j * 30 * u; p.y = (F4 - 1) * dy - j * 5 * u; p.z = -(F4 - 1) * dz - j * 22; p.ry = 19 + j * 6; p.rx = -8; }
out.push(p);
}
hi = (Math.min(n, F4) - 1) * dy; cx = (stageW < 560 ? 44 : 64) * u;
}
Every pose is a translation and three rotations. One small function turns it into a matrix, and the same matrix
becomes the card's matrix3d (with the y axis flipped, because CSS points down). Stage 2 hands the very same
matrix to the ice.
function worldMatrix(s, out = new THREE.Matrix4()) {
out.makeTranslation(s.x + s.piv, s.y, s.z);
tmpE.set(s.rx * DEG, s.ry * DEG, s.rz * DEG, 'YXZ'); tmpR.makeRotationFromEuler(tmpE);
out.multiply(tmpR); out.multiply(tmpM.makeTranslation(-s.piv, 0, 0));
return out;
}
const cssM = new THREE.Matrix4();
function cssMatrix(m) { cssM.copy(F).multiply(m).multiply(F); return 'matrix3d(' + cssM.elements.map((v) => +v.toFixed(5)).join(',') + ')'; }
The cards never jump: every value of the pose is a damped spring, stepped in sub-steps of at most 9 ms so it stays stable at any frame rate. The loop sleeps when nothing moves.
function step(dt) {
let moving = false;
const kSt = reduced ? 900 : 190, kDa = reduced ? 60 : 22;
for (const n of notes) {
const st = n.stiff || kSt, da = n.damp || kDa;
for (const key of POSE_KEYS) {
if (key === 'piv') { n.s.piv = n.tg.piv; continue; }
const x = n.s[key], v = n.v[key], target = n.tg[key];
const a = -st * (x - target) - da * v;
const nv = v + a * dt, nx = x + nv * dt;
n.v[key] = nv; n.s[key] = nx;
if (Math.abs(nx - target) > 0.02 || Math.abs(nv) > 0.05) moving = true;
}
n.o += (n.ot - n.o) * Math.min(1, dt * (reduced ? 60 : n.ot < n.o ? 22 : 9));
if (Math.abs(n.o - n.ot) > 0.004) moving = true;
if (n.stiff && !moving) { n.stiff = 0; n.damp = 0; }
}
return moving;
}
Stacked glass has a problem paper doesn't: you would see the text of the card behind through the card in front. So each card works out where the newer card covers it, in its own coordinates, and fades the lines of text that the cover touches. Text never shows through ice and is never cut mid-line.
// Where a card drawn above covers this one, in this card's own CSS px: the cover's corners carried into this card's
// frame and projected onto its plane along the view ray. A text line that any cover touches fades out, so text never
// shows through the ice of a newer card and never gets cut mid-line.
const inv = new THREE.Matrix4(), v3 = new THREE.Vector3(), eye = new THREE.Vector3();
function quadOn(back, front) {
inv.copy(back.m).invert();
eye.set(0, 0, PERSP).applyMatrix4(inv);
const pad = 6 + D * 0.3, pts = [];
for (const [x, y] of [[-1, 1], [1, 1], [1, -1], [-1, -1]]) {
v3.set(x * (W / 2 + pad), y * (H / 2 + pad), 0).applyMatrix4(front.m).applyMatrix4(inv);
const t = eye.z / (eye.z - v3.z);
pts.push([eye.x + (v3.x - eye.x) * t + W / 2, H / 2 - (eye.y + (v3.y - eye.y) * t)]);
}
return pts;
}
Point at the stack, tap it or Tab into it, and it fans out; the card under the pointer lifts towards you. The keyboard follows the list pattern: one tab stop, arrows, Home and End, Enter opens, Escape leaves.
el.addEventListener('pointerenter', (e) => {
if (e.pointerType !== 'mouse') return;
clearTimeout(leaveT); hoverInside = true;
// while a page slides out of the fan, the pages it uncovers do not steal the hover (no flip-flopping)
const now = performance.now(), k = notes.indexOf(n);
if (now < hoverLock && hoverId != null && hoverId !== n.id && k >= 4) return;
hoverId = n.id; if (expanded && k >= 4) hoverLock = now + 520;
syncOpen(); retarget();
});
card.addEventListener('keydown', (e) => {
const k = notes.indexOf(n);
const go = (i) => { const t = notes[Math.max(0, Math.min(notes.length - 1, i))]; if (t) t.card.focus({ preventScroll: true }); };
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); go(k + 1); }
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); go(k - 1); }
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(notes.length - 1); }
else if ((e.key === 'Enter' || e.key === ' ') && e.target === card) { e.preventDefault(); openNote(n); }
else if (e.key === 'Escape') { e.preventDefault(); pinned = false; region.focus({ preventScroll: true }); }
});
For now the cards are frosted CSS. This stays as the fallback when WebGL is not available.
html.nogl .card { background: linear-gradient(170deg, rgb(236 246 255 / .9) 0%, rgb(201 222 238 / .86) 46%, rgb(160 199 226 / .84) 100%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / .95), inset 0 0 0 1px rgb(255 255 255 / .55), inset 0 -10px 24px -10px rgb(255 255 255 / .6), 0 0 0 1px rgb(120 170 210 / .35),

2 · The ice
A WebGL canvas goes under the cards. If it can be made, the page switches to the ice; if not, the CSS cards of stage 1 stay.
ice = createIce({ back: backCv, motion: reduced ? 0 : 1 });
html.classList.toggle('gl', !!ice); html.classList.toggle('nogl', !ice);
The trick is the camera. A CSS perspective of P pixels is a pinhole camera P pixels in front of the page, looking at the perspective origin. Build the three.js camera the same way, with a view offset so its centre is the stack's centre and not the canvas's, and a slab drawn with a card's matrix lands under that card to the pixel.
// perspective P (CSS px) with its origin at (x, y) in canvas CSS px: the camera now matches the CSS 3D layer exactly
setView(x, y, persp) {
ax = x; ay = y; P = persp;
const fullW = 2 * Math.max(x, vw - x), fullH = 2 * Math.max(y, vh - y);
camera.fov = 2 * Math.atan(fullH / 2 / P) * 180 / Math.PI; camera.aspect = fullW / fullH; camera.near = 10; camera.far = P + 3000;
camera.position.set(0, 0, P); camera.lookAt(0, 0, 0);
camera.setViewOffset(fullW, fullH, fullW / 2 - x, fullH / 2 - y, vw, vh);
camera.updateProjectionMatrix();
bgU.uAnchor.value.set(x, y);
}
if (ice) ice.update(n.id, n.m, z >= 500 ? -1 : k, n.glow, n.hov, 1);
Each slab is an extruded rounded rectangle with a real bevel, so the edge catches light and has thickness, and a few icicles hang from its bottom edge.
function slabGeometry(W, H, R, D, variant) {
const bevel = Math.max(2.5, D * 0.42);
const g = new THREE.ExtrudeGeometry(roundedShape(W - bevel * 2, H - bevel * 2, Math.max(R - bevel, 2)),
{ depth: D, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 4, curveSegments: 10 });
g.translate(0, 0, -D - bevel); // the front face sits on the card plane (z = 0)
let geo = g.index ? g.toNonIndexed() : g;
// icicles under the bottom edge
const rnd = rng(17 + variant * 101), parts = [geo];
const count = 4 + Math.floor(rnd() * 4);
for (let i = 0; i < count; i++) {
const len = 6 + Math.pow(rnd(), 1.6) * 24, rad = 1.6 + rnd() * 2.6;
const c = new THREE.ConeGeometry(rad, len, 7, 1, true).toNonIndexed();
c.rotateX(Math.PI); // tip down
const x = (rnd() - 0.5) * (W - R * 2.4), z = -D * (0.2 + rnd() * 0.6);
c.translate(x, -H / 2 - len / 2 + 1.5, z); parts.push(c);
}
return merge(parts);
}
The ice shader refracts a half-resolution render of the night behind it, adds a pale blue body, crystal veins, round frost glints, a slow light sweep and a hoar-frost crust that creeps in from the rim. Noise is expensive per pixel, so it is baked once into a tileable texture and only sampled.
float crust=smoothstep(.5, 1., edge+(B.r-.5)*1.15+(E.a-.5)*.35)*(.7+.3*E.g);
vec2 suv=gl_FragCoord.xy/uRes;
vec2 off=vec2(n2-.5, n1-.5)*.014+normalize(p+1e-3)*edge*.016;
vec3 bg=bgBlur(suv+off);
vec3 body=mix(vec3(.58,.74,.87), vec3(.80,.885,.95), smoothstep(-.05, 1.05, uv.y*.85+uv.x*.15));
body=mix(body, body*.72+bg*.55, .30);
body+=(n1-.5)*.07+(n3-.5)*.035;
body+=veins*.035*(1.-edge);
vec3 col=mix(body, vec3(.93,.965,.99), clamp(crust*.9, 0., 1.));
// ---------------------------------------------------------------- frost noise, baked once into a tileable texture
// R: fbm, G: fbm (other seed), B: crystal cells (F2-F1), A: fine fbm. The ice samples it instead of computing noise per pixel.
Tilted planes cross each other, so plain depth testing would let an older card poke through a newer one. Each card draws in its own thin depth layer by rank instead: the newest is always in front, and the GPU can still reject hidden pixels early.
// uLayer: each card draws in its own thin depth layer by rank (newest nearest), so a newer card always covers an older
// one even where their tilted planes cross, and the GPU can still reject hidden pixels early (opaque, front to back)
const SLAB_VS = `uniform float uLayer;
varying vec3 vPos; varying vec3 vN; varying vec3 vLN; varying vec3 vV;
void main(){ vPos=position; vLN=normal; vN=normalize(normalMatrix*normal); vec4 mv=modelViewMatrix*vec4(position,1.); vV=mv.xyz; gl_Position=projectionMatrix*mv;
gl_Position.z=(uLayer+clamp(gl_Position.z/gl_Position.w,-1.,1.)*.002)*gl_Position.w; }`;

3 · New notifications fly in
A second canvas goes over the cards for things in flight. It never takes pointer events.
<canvas class="gl gl-fx" aria-hidden="true"></canvas>
A new card starts beyond the left edge of the page with a push of speed, and a softer spring carries it into the front of the stack. The live region announces it.
if (opts.arrive) {
const { out } = layout();
const tp = out[0];
n.s = { ...tp, x: -(originX + W * 1.1) - 60, y: tp.y + 26 * u, z: 60, ry: -14, rz: 3, rx: 0 };
n.v = { ...zeroPose(), x: reduced ? 0 : 900 };
if (reduced) n.s = { ...tp };
n.stiff = reduced ? 0 : 120; n.damp = reduced ? 0 : 15.5; n.arriving = !reduced;
n.o = 1;
announce(`New notification. ${d.name}: ${d.title}. ${d.who}.`);
}
While it flies, speed streaks trail behind it: 46 thin quads attached to the card's own matrix, their length scaled by its speed, all in the vertex shader. The wind in the night behind surges with it.
const STREAK_VS = `attribute vec4 aR; uniform mat4 uCard; uniform vec3 uSize; uniform float uSpeed; uniform float uTime;
varying float vA; varying float vU;
void main(){
float len=(80.+aR.x*320.)*uSpeed; float y=(aR.y-.5)*uSize.y*1.1; float th=1.+aR.z*2.6;
float x0=-uSize.x*.5+8.-aR.w*40.*uSpeed;
vec2 q=vec2(x0-len*(1.-position.x), y+(position.y-.5)*th);
vU=position.x; vA=uSpeed*(.25+.6*aR.z);
gl_Position=projectionMatrix*viewMatrix*uCard*vec4(q, -2.-aR.w*10., 1.);
}`;
bgU.uStreak.value = Math.max(bgU.uStreak.value, speed * 0.6);
The demo sends one soon after load, then one now and then, only while nobody is reading the stack.
// the demo: one arrival soon after load, then now and then while nobody is reading
if (!quiet) {
let auto = 0;
setTimeout(function tick() {
if (auto >= 4) return;
if (!expanded && document.visibilityState === 'visible' && notes.length) { arrive(); auto++; }
setTimeout(tick, 17000);
}, 2200);
}

4 · The shatter
Dismissing is where the material pays off. A card can go three ways: its × button, a swipe past 30 % of its width (or a quick flick), or the Delete key. The swipe measures its own speed from the last few pointer moves.
const end = (e) => {
if (!st || e.pointerId !== st.id) return;
const was = dragging && dragging.n === n ? dragging : null; const s0 = st; st = null;
if (!was) return;
dragging = null; el.classList.remove('is-drag');
const h = s0.hist, a = h[0], b = h[h.length - 1], vx = (b[1] - a[1]) / Math.max(16, b[0] - a[0]);
if (Math.abs(was.dx) > W * 0.3 || Math.abs(vx) > 0.6) {
const dir = Math.sign(was.dx || vx);
n.v.x = vx * 1000;
dismiss(n, dir, [dir * W * 0.45, 0]);
} else { n.stiff = 0; n.damp = 0; retarget(); }
n.dragEnd = performance.now();
syncOpen();
};
The card's slab is removed and, in the same frame, a pre-fractured copy takes its place at the same matrix.
function dismiss(n, dir = 1, impact = null, silent = false) {
const k = notes.indexOf(n); if (k < 0) return;
const hadFocus = n.el.contains(document.activeElement);
notes.splice(k, 1);
const m = n.m ? n.m.clone() : worldMatrix(n.s);
if (ice) {
ice.remove(n.id);
if (!reduced) ice.shatter(m, dir, impact || [dir * W * 0.42, 0], [n.v.x * 0.5, n.v.y * 0.5], performance.now());
}
n.el.classList.add('is-shattering'); n.el.inert = true;
setTimeout(() => n.el.remove(), reduced ? 200 : 260);
if (focusId === n.id) focusId = null;
if (hoverId === n.id) hoverId = null;
if (!silent) announce(`Dismissed. ${n.d.name}: ${n.d.title}.`);
retarget(); renderCount();
if (hadFocus) { const next = notes[Math.min(k, notes.length - 1)]; if (next) next.card.focus({ preventScroll: true }); else clearBtn.disabled ? region.focus({ preventScroll: true }) : clearBtn.focus(); }
if (!notes.length) scheduleRefill();
wake();
}
The fracture is a Voronoi diagram: sites crowd around the impact point, each site keeps the part of the card closer to it than to any other site, and each piece is extruded to the slab's depth. Three fractures (right, left, low) are made when the cards are sized, not when one breaks.
// Voronoi fracture of the rounded card into convex shards, extruded to the slab's depth
function fractureGeometry(W, H, R, D, impact, seed) {
const rnd = rng(seed);
const outline = []; // the rounded rectangle as a convex polygon
const corners = [[W / 2 - R, H / 2 - R, 0], [-W / 2 + R, H / 2 - R, Math.PI / 2], [-W / 2 + R, -H / 2 + R, Math.PI], [W / 2 - R, -H / 2 + R, Math.PI * 1.5]];
for (const [cx, cy, a0] of corners) for (let i = 0; i <= 5; i++) { const a = a0 + (i / 5) * Math.PI / 2; outline.push([cx + Math.cos(a) * R, cy + Math.sin(a) * R]); }
const sites = [];
for (let i = 0; i < 84; i++) {
if (i < 58) { const a = rnd() * Math.PI * 2, r = Math.pow(rnd(), 1.5) * W * 0.55; sites.push([impact[0] + Math.cos(a) * r, impact[1] + Math.sin(a) * r * 0.6]); }
else sites.push([(rnd() - 0.5) * W, (rnd() - 0.5) * H]);
}
const clip = (poly, a, b) => { // keep the half-plane closer to a than b
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2, nx = b[0] - a[0], ny = b[1] - a[1];
const side = (p) => (p[0] - mx) * nx + (p[1] - my) * ny; const out = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i], q = poly[(i + 1) % poly.length], sp = side(p), sq = side(q);
if (sp <= 0) out.push(p);
if ((sp <= 0) !== (sq <= 0)) { const t = sp / (sp - sq); out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]); }
}
return out;
};
const P = [], Nn = [], C = [], Rr = [];
for (let i = 0; i < sites.length; i++) {
let poly = outline.map((p) => p.slice());
for (let j = 0; j < sites.length && poly.length >= 3; j++) if (j !== i) poly = clip(poly, sites[i], sites[j]);
if (poly.length < 3) continue;
let cx = 0, cy = 0; for (const p of poly) { cx += p[0]; cy += p[1]; } cx /= poly.length; cy /= poly.length;
const r4 = [rnd(), rnd(), rnd(), rnd()], cz = -D / 2;
const push = (x, y, z, nx, ny, nz) => { P.push(x - cx, y - cy, z - cz); Nn.push(nx, ny, nz); C.push(cx, cy, cz); Rr.push(...r4); };
for (let k = 0; k < poly.length; k++) {
const a = poly[k], b = poly[(k + 1) % poly.length];
push(cx, cy, 0, 0, 0, 1); push(a[0], a[1], 0, 0, 0, 1); push(b[0], b[1], 0, 0, 0, 1); // front
push(cx, cy, -D, 0, 0, -1); push(b[0], b[1], -D, 0, 0, -1); push(a[0], a[1], -D, 0, 0, -1); // back
const ex = b[0] - a[0], ey = b[1] - a[1], l = Math.hypot(ex, ey) || 1, nx = ey / l, ny = -ex / l; // side
push(a[0], a[1], 0, nx, ny, 0); push(a[0], a[1], -D, nx, ny, 0); push(b[0], b[1], 0, nx, ny, 0);
push(b[0], b[1], 0, nx, ny, 0); push(a[0], a[1], -D, nx, ny, 0); push(b[0], b[1], -D, nx, ny, 0);
}
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); g.setAttribute('normal', new THREE.Float32BufferAttribute(Nn, 3));
g.setAttribute('aC', new THREE.Float32BufferAttribute(C, 3)); g.setAttribute('aR', new THREE.Float32BufferAttribute(Rr, 4));
return g;
}
Every shard flies in the vertex shader from one start time: away from the impact, in the swipe's direction, with air drag, gravity and a tumble. A burst of 3,400 frost particles and a little mist goes with it. The CPU does nothing per frame, and the meshes come from a pool, so a shatter never stutters.
const SHARD_VS = `attribute vec3 aC; attribute vec4 aR;
uniform mat4 uCard; uniform float uAge; uniform vec2 uImpact; uniform float uDir; uniform vec2 uVel;
varying vec3 vN; varying vec3 vV; varying float vFade; varying vec2 vP; varying float vB;
mat3 rot(vec3 a, float t){ a=normalize(a); float s=sin(t), c=cos(t), o=1.-c;
return mat3(o*a.x*a.x+c, o*a.x*a.y+a.z*s, o*a.z*a.x-a.y*s, o*a.x*a.y-a.z*s, o*a.y*a.y+c, o*a.y*a.z+a.x*s, o*a.z*a.x+a.y*s, o*a.y*a.z-a.x*s, o*a.z*a.z+c); }
void main(){
vec2 away=aC.xy-uImpact; float dist=length(away);
float t=max(uAge-dist/2600., 0.);
vec3 vel=vec3(away/max(dist,1.)*(150.+380.*aR.x)+vec2(uDir*(480.+760.*aR.z), 90.*(aR.y-.35)), (aR.w-.35)*460.);
vel.xy+=uVel*.6;
float dr=1.-exp(-2.2*t); // air drag
vec3 c=aC+vel*dr/2.2+vec3(0.,-340.*t*t,0.);
float life=.32+.4*aR.y;
float s=1.-smoothstep(.05, 1., t/life);
mat3 R=rot(aR.xyz*2.-1.+.001, (aR.w*2.-1.)*10.*t);
vec3 p=c+R*(position*s);
vec4 mv=viewMatrix*uCard*vec4(p,1.);
vV=mv.xyz; vN=normalize(mat3(viewMatrix*uCard)*(R*normal));
vFade=s*(1.-smoothstep(.0,.25, t-life+.25)); vP=position.xy; vB=aR.y;
gl_Position=projectionMatrix*mv;
}`;
The DOM text blurs out as the ice breaks.
.is-shattering { pointer-events: none; }
.is-shattering .card-in { opacity: 0 !important; filter: blur(3px); transition: opacity .14s ease, filter .14s ease; }
.is-shattering .x { opacity: 0 !important; }
Clear all shatters the stack one card after another, 95 ms apart, then "All caught up" can bring them back.
function clearAll() {
if (!notes.length || clearing) return;
clearing = true; const all = notes.slice(); const hadFocus = region.contains(document.activeElement);
expanded = false; pinned = false; setPinnedUI();
all.forEach((n, i) => setTimeout(() => { dismiss(n, 1, [W * 0.42, (Math.random() - 0.5) * H * 0.4], true); if (i === all.length - 1) { clearing = false; if (hadFocus) region.focus({ preventScroll: true }); } }, reduced ? 0 : i * 95));
announce('All notifications cleared.');
}
The three tiles under the stack replay each state on the live stack; their pictures are screenshots of this page in that state.
// the three state tiles replay their state on the live stack
document.querySelectorAll('[data-demo]').forEach((b) => b.addEventListener('click', () => {
const what = b.dataset.demo;
if (what === 'arrive') { pinned = false; setPinnedUI(); syncOpen(); arrive(); }
else if (what === 'fan') { pinned = !pinned; setPinnedUI(); syncOpen(); retarget(); }
else if (what === 'shatter') {
if (!notes.length) { restore(); return; }
dismiss(notes[0], 1, [W * 0.42, H * 0.1]);
}
}));

5 · The frame budget
Last, the page has to stay smooth on a 120 Hz screen and on a slow phone. The ice is held to about 2.4 million pixels and a pixel ratio of 1.5 (frosted glass hides it), and steps down if a third of the frames in a second run long.
const dpr = Math.max(0.75, Math.min(devicePixelRatio || 1, 1.5, Math.sqrt(2.4e6 / Math.max(1, pw * ph))) - quality);
// adaptive quality: if more than a third of frames run long for a second, drop the ice's pixel ratio a step
function watch(dt) {
seen++; if (dt > 0.0125) slow++;
if (seen >= 90) { if (slow > seen / 3 && quality < 0.5) { quality += 0.25; resize(); } seen = 0; slow = 0; }
}
At rest only the slow light moves, so the idle frames are drawn at 60 fps; anything in motion gets every frame.
// at rest only the slow idle light moves: draw it on every other display frame (60 fps), the GPU gets half the work
if (ice && (busy || (tick++ & 1) === 0)) { ice.render(now, (now - t0) / 1000); watch(dt); }
Shaders and pooled meshes are compiled and uploaded at load, so the first shatter does not wait for the GPU.
compile() {
const hidden = []; fxScene.traverse((o) => { if (!o.visible) { hidden.push(o); o.visible = true; } });
const probe = new THREE.Mesh(streakGeo, new THREE.ShaderMaterial({ vertexShader: STREAK_VS, fragmentShader: STREAK_FS, ...premul,
uniforms: { uCard: { value: new THREE.Matrix4() }, uSize: { value: new THREE.Vector3(W, H, R) }, uSpeed: { value: 0 }, uTime: shared.uTime } }));
probe.frustumCulled = false; fxScene.add(probe);
renderer.compile(scene, camera); fxr.compile(fxScene, camera);
fxr.render(fxScene, camera); fxr.clear(); // upload the pooled geometry now, not on the first shatter
fxScene.remove(probe); probe.material.dispose(); for (const o of hidden) o.visible = false;
},
Measured with the project's frame probe at 120 Hz through idle, fan out, lift, arrivals, swipe-shatter and Clear all: no missed frames and no long tasks at 1440 × 900 or on a 390 × 844 phone, and none with the CPU slowed 4 × on a second run.


Make it yours
- Your notifications are
SEED(what is there on load) andPOOL(what arrives) indata.js, with their app icons as small inline SVGs. - The look of the ice is the slab shader in
ice.js: the body colour, the crust and the glints are a few lines each. - No WebGL:
?noglshows the frosted CSS cards; the stack, the fan, the keyboard and dismissing all still work. - Accessibility is already there: a labelled region with a list of articles, one tab stop with arrow keys, Enter
to open, Delete to dismiss, a polite live region, visible focus, 44 px targets, and
prefers-reduced-motionhonoured (no flights, no shatters, springs snap).
The code is on GitHub at UlviShabanbayli/domvybe-frost-notifications, under the MIT licence: use it in your own projects, commercial ones included. Aura and the other apps and people are made up. Everything on the page is made in code: the night, the ice and the frost are WebGL, the icons are SVG.
Keep the words in the DOM and give the material to the GPU, and drive both with the same matrix. The text stays sharp, selectable and accessible; the glass can do anything.