Library / Tutorial

Chrome bell,
step by step.

A notification bell of polished chrome. It hangs out of a frosted glass header, swings like a real pendulum when a notification strikes it, its clapper lags and hits the bow, every hit ripples through the glass, and the badge on its shoulder rolls like an odometer. Plain HTML, CSS and JavaScript with WebGL2. Three stages, each a live page you can run.

3Stages
480Steps a second
0Libraries

This is the whole build of the chrome notification bell, in the order it was written: the header of a made-up workspace app, Halcyon. The bell is not an icon; it is a polished chrome bell hanging out of a frosted glass header. Every notification strikes it, it swings like a real pendulum, its brass clapper lags behind and hits the bow, every hit sends a ring through the glass, and the red badge on its shoulder rolls up like an odometer. 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 and seven small ES modules.

1 · The page, the panel and a bell that swings

Start with what a keyboard and a screen reader need. The bell is a real button that opens the notification panel and says so; the panel is a dialog with a real list.

index.html — the bell is a button
<button class="bell" id="bell" type="button" aria-haspopup="dialog" aria-expanded="true" aria-controls="panel" aria-describedby="bellhint">
  <span class="sr" id="belllabel">Notifications, 0 unread</span>
</button>

The badge is a pill with an odometer inside: when the count changes, the old number rolls out and the new one rolls in from below (or from above when it counts down), and the pill pops.

index.html — the badge
<span class="badge" id="badge" aria-hidden="true"><span class="odo" id="odo"></span></span>
bell.css — the rolling digits
.odo span { position: absolute; left: 0; right: 0; top: 0; transition: transform .34s cubic-bezier(.3,1.4,.5,1); }
.odo span.from-below { transform: translateY(105%); }
.odo span.from-above { transform: translateY(-105%); }
.odo span.to-above { transform: translateY(-105%); }
.odo span.to-below { transform: translateY(105%); }
app.js — roll the number
function setBadge(n) {
  const prev = shown; shown = n;
  badge.classList.toggle('zero', n === 0);
  badge.classList.toggle('paused', st.paused);
  if (n === prev && odo.firstChild) return;
  if (String(n).length !== String(prev).length) badgeSz = null;
  const old = odo.querySelector('span:not(.leaving)');
  const s = document.createElement('span'); s.textContent = n > 99 ? '99+' : String(n);
  if (!old || RM) { odo.textContent = ''; odo.appendChild(s); return; }
  const up = n > prev;
  s.className = up ? 'from-below' : 'from-above'; odo.appendChild(s);
  old.classList.add('leaving');
  requestAnimationFrame(() => { s.className = ''; old.className = 'leaving ' + (up ? 'to-above' : 'to-below'); });
  setTimeout(() => old.remove(), 400);
  if (up && !RM) { badge.animate([{ scale: '1' }, { scale: '1.22' }, { scale: '1' }], { duration: 420, easing: 'cubic-bezier(.3,1.6,.5,1)' }); }
}

A notification is one function: it goes to the top of the list, slides in, strikes the bell and is announced.

app.js — a notification arrives
function arrive(it, { silent = false } = {}) {
  st.items.unshift(it);
  renderList(it.id);
  list.scrollTop = 0;
  if (!st.paused && !silent) strike(4.1);
  announce(`New notification: ${it.say}. ${unread()} unread.`);
}

Now the bell. It is a damped pendulum about its loop: gravity pulls it back, a little damping and a touch of dry friction let it come to rest in about six seconds instead of trailing on for ever.

physics.js — the pendulum
const al = -(G / LB) * Math.sin(s.th) - CB * s.w - DRY * Math.tanh(s.w / 0.06) + tq;
const w0 = s.w;

The clapper is a second pendulum hanging inside it. Its pivot moves with the bell, so it lags behind, and when the angle between them reaches the sound bow, it strikes: it bounces back with some restitution, a little momentum goes back into the bell, and the strike is reported with its strength.

physics.js — the clapper strikes the bow
const rel = s.ph - s.th;
if (Math.abs(rel) > LIM) {
  const sg = Math.sign(rel), vr = s.pw - s.w;
  s.ph = s.th + sg * LIM;
  if (vr * sg > 0) {
    const imp = Math.abs(vr);
    const nv = -E * vr;
    // a light clapper against a heavy bell: most of the exchange stays in the clapper
    s.w += (vr - nv) * 0.06;
    s.pw = s.w + nv;
    if (imp > 0.35) { s.strikes++; for (const f of listeners) f(Math.min(1, imp / 4.2), sg); }
  }
}

A notification tops the swing up to the energy of one strike, never past it, so ten notifications in a row do not wind the bell up into a spin, and it kicks the clapper so there is always one sure ring.

physics.js — a strike
s.strike = (v, dir, kick = 1.15) => {
  const pot = 2 * (G / LB) * (1 - Math.cos(s.th)), E = s.w * s.w + pot;
  const d = dir || (Math.abs(s.w) > 0.25 ? Math.sign(s.w) : (s.side = -s.side));
  if (E < v * v) s.w = d * Math.sqrt(Math.max(0, v * v - pot));
  else s.w += d * 0.12 * v;
  // the clapper is left behind by the jolt, so it meets the sound bow on the trailing side: one sure strike
  if (!s.hold && kick) s.pw = s.w - d * kick * v;
};

It runs in fixed steps of 1/480 s whatever the display's refresh rate, so the bell swings the same on every screen.

physics.js — fixed steps
s.advance = (dt) => {
  s.acc += Math.min(dt, 0.05);
  while (s.acc >= DT) { step(); s.acc -= DT; }
};

For now the bell is drawn as an SVG with the same proportions as the mesh it will become. Two rotations are all it needs: the bell about its loop, the clapper about its own pivot.

fallback.js — draw the swing
update(theta, rel) {
  swing.setAttribute('transform', `rotate(${(-theta * 180 / Math.PI).toFixed(3)})`);
  clap.setAttribute('transform', `rotate(${(-rel * 180 / Math.PI).toFixed(3)} 0 ${-GEO.clapPivot})`);
},

Every strike sends a ring through the frosted CSS header, from the bell's mouth.

app.js — a ring per strike
bell.onStrike((amp, side) => {
  if (!L) return;
  // a ring through the frosted header, from the bell's mouth
  const p = project(rotation(bell.th, bell.psi), [0, GEO.lipY, 0], [L.piv[0] - L.hdrS[0], L.piv[1] - L.hdrS[1]], L.S);
  cssRing($('rings'), p[0], p[1], 3.2 * L.S, 0.4 + 0.6 * amp);
});

You can take the bell by hand. A drag turns into a target angle and a stiff spring pulls the bell to it; let go and it swings free, the clapper ringing as it goes. The arrow keys swing it too.

app.js — drag the bell
if (drag.moved && !RM) {
  const a = Math.atan2(x - L.piv[0], Math.max(8, y - L.piv[1]));
  bell.grab = { target: Math.max(-0.8, Math.min(0.8, drag.th0 + (a - drag.a0) * 1.6)) };
  wake();
}
physics.js — the hand is a spring
if (s.grab) tq = 90 * (s.grab.target - s.th) - 9 * s.w;
else if (s.leanOn) tq = 7 * (s.lean - s.th);
app.js — the keys
bellBtn.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); strike(3.6, e.key === 'ArrowRight' ? 1 : -1); }
  else if (e.key === 'ArrowDown' && st.open) { e.preventDefault(); const f = list.querySelector('.n'); if (f) f.focus(); }
});

Mark all read counts the badge down step by step, so you see the number roll to zero before it leaves.

app.js — count down
// the badge counts down step by step, then leaves
let n = before;
const tick = () => { n--; setBadge(n); if (n > 0) setTimeout(tick, RM ? 0 : 110); else syncCounts(); };

When the bell is at rest, the loop stops. Nothing runs on an idle page.

app.js — sleep at rest
const rest = RM || bell.atRest();
if (rest && !RM) { const tgt = bell.leanOn && !bell.grab ? bell.lean : 0; bell.th = tgt; bell.w = 0; bell.ph = bell.hold ? tgt : 0; bell.pw = 0; bell.psi *= 0.9; if (Math.abs(bell.psi) < 1e-4) bell.psi = 0; }
const moving = !rest || Math.abs(bell.psi) > 0;
render();
Stage 1 · the page and the SVG bellOpen ↗
The Halcyon workspace with a frosted CSS glass header, an SVG chrome-like bell swinging out of its right end with a red badge 1 on its shoulder, a CSS ring in the glass, the greeting Good morning, Ana., the notification panel with one item and three project cards.

2 · The chrome

Two transparent canvases join the page, each only as big as what it shows: one for the glass header and its shadow, one for the box the bell can swing through.

index.html — the glass canvas
<canvas id="fx" aria-hidden="true"></canvas>
app.js — canvases sized to what they show
// glass canvas: the header + its shadow + the bell's shadow on the page; bell canvas: the box the bell swings through
const S = F.S, [px, py] = F.piv, h = F.hdr;
const vx0 = -sr.left / k, vx1 = (W - sr.left) / k;
const box = (x0, y0, x1, y1) => { x0 = Math.max(x0, vx0); x1 = Math.min(x1, vx1); y0 = Math.max(y0, -10); return [x0 * k, y0 * k, (x1 - x0) * k, (y1 - y0) * k]; };
const cg = box(h[0] - 90, h[1] - 40, h[0] + h[2] + 90, Math.max(h[1] + h[3] + 110, py + 1.25 * S));
const cb = box(px - 1.04 * S, py - 0.14 * S, px + 1.04 * S, py + 1.16 * S);

The bell is a lathe: a 2D profile turned round its axis. The crown is a dome, the waist and the sound bow follow one flare curve, and there is a rolled lip with a beaded rope ring above it.

bellmesh.js — the flare of the bell
function flare(t) {
  const r = 0.207 + 0.022 * t + 0.214 * Math.pow(t, 2.65);
  return r;
}
bellmesh.js — the crown
// crown dome
const DC = -0.305, DR = 0.205, DH = 0.178;
for (let i = 0; i <= 26; i++) { const a = 0.27 + (Math.PI / 2 - 0.27) * i / 26; add(DR * Math.sin(a), DC + DH * Math.cos(a), 0); }
bellmesh.js — normals straight from the profile
// normals from the profile tangent: n = (-dy, dr) is outward on the way down and into the cavity on the way up

Chrome is only as good as what it reflects. The reflections come from a small studio described in a shader: the page's pastel colours all round the horizon, a dark violet belt on the far wall that becomes the bell's dark band, soft boxes and black flags for the streaks.

shaders.js — the studio: the page round the horizon
// the page all round the horizon: peach on the left, warm cream in front, lilac and sky on the right
float side = d.x;
vec3 page = mix(vec3(1.0, 0.62, 0.48), vec3(1.0, 0.82, 0.66), smoothstep(-0.9, 0.0, side));
page = mix(page, vec3(0.70, 0.60, 1.0), smoothstep(-0.1, 0.65, side));
page = mix(page, vec3(0.55, 0.76, 1.0), smoothstep(0.55, 1.0, side) * smoothstep(-0.2, 0.3, d.y + 0.15));
page = mix(page, vec3(0.82, 0.72, 0.95), smoothstep(0.2, -0.9, d.z) * 0.6);
shaders.js — the dark belt
// the far wall line: a deep violet band just above the horizon (chrome's dark belt) and a thinner one higher up
c = mix(c, vec3(0.06, 0.05, 0.11), 0.95 * exp(-pow((el - 0.14) / 0.08, 2.0)));
c = mix(c, vec3(0.16, 0.14, 0.25), 0.6 * exp(-pow((el - 0.52) / 0.04, 2.0)));

That studio is rendered once into a 512² cube map with mipmaps, so the reflections never shimmer and cost one texture read per pixel.

renderer.js — bake the studio once
// the studio → a 512² cube map, once. Half float when it can be rendered to (HDR soft boxes), else RGBA8 scaled by ¼.
function bakeEnv() {
  const size = 512, flt = !!gl.getExtension('EXT_color_buffer_float');
  envTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_CUBE_MAP, envTex);

The chrome itself is a mirror with a Fresnel edge: almost all of the studio comes back, and a little more at grazing angles.

shaders.js — mirror chrome
vec3 F0 = vec3(0.93, 0.92, 0.96);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - cv, 5.0);
vec3 R = reflect(V, N);
col = env(R) * F;

The glass header is a shader too. The page behind it is blurred once into a texture, and the glass refracts it with a little dispersion through a pillow bevel.

app.js — the frost, once per layout
// the frost the glass refracts: the same page, blurred
const f = document.createElement('canvas'), s = 0.5; f.width = Math.round(W * s); f.height = Math.round(H * s);
const fg = f.getContext('2d'); fg.filter = 'blur(10px)'; fg.drawImage(c, -20, -20, f.width + 40, f.height + 40);
fg.filter = 'none'; fg.globalCompositeOperation = 'destination-over'; fg.drawImage(c, 0, 0, f.width, f.height);
shaders.js — refraction
// refraction of the frosted page with a little dispersion
vec2 off = -g * 1.6;
vec3 col = vec3(page(p + off).r, page(p + off * 1.12).g, page(p + off * 1.25).b);

Each strike starts a ring at the bell's mouth. A ring is a wave packet in the glass's height field: two or three crests that bend the page as they pass, catch the light and carry a faint thin-film shimmer, then fade with distance.

app.js — a strike starts a ring
if (fxOn) {
  const p = mouthC();
  rings.unshift({ x: p[0], y: p[1], r: 0.3 * L.S, a: 0.5 + 0.7 * amp });
  if (rings.length > 6) rings.length = 6;
} else {
app.js — rings travel and fade
// rings travel through the glass and fade
const sp = 430 * L.k;
for (const r of rings) { r.r += sp * dt; r.a *= Math.exp(-dt * 0.7); }
for (let i = rings.length - 1; i >= 0; i--) if (rings[i].a / (1 + rings[i].r / (650 * L.k)) < 0.02 || rings[i].r > 2600 * L.k) rings.splice(i, 1);
shaders.js — the wave packets
for (int i = 0; i < 6; i++) {
  vec4 R = uRing[i];
  if (R.w > 0.002) {
    vec2 v = p - R.xy; float dist = length(v) + 1e-3; float x = (dist - R.z) / W;
    if (abs(x) < 8.0) {
      float env = exp(-x * x * 0.2);
      float ph = x * 2.3;
      float dh = R.w * env * (-2.3 * sin(ph) - 0.4 * x * cos(ph)) / W;   // d/ddist of env*cos(ph)
      g += (v / dist) * dh * 38.0 * uK * rfade;
      float c = R.w * env * max(cos(ph), 0.0) * rfade;
      crest += c; irid += c * x;
    }
  }
}
shaders.js — the crest and its shimmer
// ring crests: a bright line with a faint thin-film shimmer on its flanks
vec3 film = 0.5 + 0.5 * cos(6.2832 * (vec3(0.0, 0.33, 0.67) + irid * 0.18 + 0.15));
col += mix(vec3(1.0), film, 0.45) * crest * 0.62;

The loop still sleeps when nothing moves; after a busy spell one light sweep crosses the glass first. Without WebGL2 the SVG bell of stage 1 stays.

app.js — sleep at rest
const moving = !rest || rings.length > 0 || pulse > 0 || sheen >= 0 || Math.abs(bell.psi) > 0;
// after a busy spell settles, one light sweep crosses the glass (then the loop sleeps)
if (!moving && sheenAt && now - sheenAt > 600 && fxOn && !RM) { sheenAt = 0; sheen = 0; }
app.js — WebGL2, or the SVG bell
function startFx() {
  try {
    R = createGlass(canvas);
    RB = R && createBellRenderer(bcanvas);
  } catch (e) { console.warn('[bell] WebGL failed, using the CSS fallback', e); R = RB = null; }
  if (!R || !RB) { R = RB = null; html.classList.add('no-fx'); return false; }
  fxOn = true;
  return true;
}
Stage 2 · the chromeOpen ↗
The same page with the WebGL2 bell: a polished chrome bell with white streaks and a dark violet band, swinging out of a frosted glass header that refracts the pastel page, concentric rings rippling through the glass from its mouth, the red badge 1 on its shoulder.

3 · The finish

A bell can also be heard. The chime is off by default; the speaker in the panel turns it on. It is a small hand bell made of inharmonic partials, each with its own decay, louder for a harder strike.

audio.js — a bell from sine waves
for (const [r, a, d] of PARTIALS) {
  const o = ctx.createOscillator(), g = ctx.createGain();
  o.type = 'sine'; o.frequency.value = f0 * r * (1 + (Math.random() - 0.5) * 0.002);
  g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(a, t + 0.003); g.gain.exponentialRampToValueAtTime(0.0001, t + d * 1.4);
  o.connect(g); g.connect(out); o.start(t); o.stop(t + d * 1.4 + 0.05);
}
app.js — the speaker toggle
$('sound').addEventListener('click', () => {
  st.sound = !st.sound; $('sound').setAttribute('aria-pressed', String(st.sound));
  try { localStorage.setItem(KEY, st.sound ? '1' : '0'); } catch (e) {}
  if (st.sound) chime(0.5, 0);
  announce(st.sound ? 'Sound on' : 'Sound off');
});

The choice is remembered under one versioned key, and older keys of the component are wiped before the first paint, where ?nofx can also ask for the SVG bell.

index.html — wipe old keys
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv92/.test(k) && !/^dv92\.v1\./.test(k)) localStorage.removeItem(k); }
index.html — ?nofx
if (q.has('nofx')) d.classList.add('no-fx');

A lost WebGL context drops to the SVG bell instead of an empty space, and comes back when the context does. A small test hook reports the state.

app.js — a lost context
const lost = () => { fxOn = false; html.classList.remove('fx-on'); html.classList.add('fx-lost'); ensureFallback(); dirty = true; wake(); };
const restored = () => { if (R.ok && RB.ok) { html.classList.remove('fx-lost'); fxOn = true; layout(); html.classList.add('fx-on'); dirty = true; wake(); } };
app.js — the test hook
window.__bell = {
  ready, st, bell, rings,
  get fx() { return R; }, get fxb() { return RB; },
Stage 3 · the finished bellOpen ↗
The finished bell: the polished chrome bell swinging out of the glass header, rings rippling through the glass, the red badge on its shoulder, the notification panel under it.
Stage 3 · on a phoneOpen ↗
The chrome bell on a phone: the Halcyon hero card with Good morning, Ana. and the chrome bell swinging on its right with a red badge 1 and rings in the glass, the notification panel and three project cards below.

Make it yours

  • Your notifications are the INTRO and POOL lists in app.js: an avatar, the text and a meta line each. Call arrive() from your own events.
  • Your bell is in physics.js: G and LB set how fast it swings, CB and DRY how soon it settles, E how hard the clapper bounces.
  • Your metal is the studio in shaders.js: a gold bell is a warmer F0, a darker room makes the chrome moodier.
  • Accessibility is already there: the bell is a button with aria-expanded and aria-controls, the panel a dialog with a real list, arrow keys in the list, Escape and an outside click close it and give focus back, new notifications are announced, a visible focus ring, 44 px targets on touch, prefers-reduced-motion (the bell does not swing) and the SVG bell without WebGL2.

Halcyon and everyone in its notifications are made up. Everything on the page was made in code for this project: the bell is a mesh built in JavaScript, the chrome and the glass are shaders, the chime is synthesised.

The one idea worth keeping

Let one honest piece of physics drive everything else. The rings, the badge's ride on the shoulder and the chime all follow the same pendulum, so the bell feels like an object, not an animation.