This is the whole build of the glow sign-up, in the order it was written: the newsletter page of a made-up Sunday letter, Noctiluca, over a night sea under the moon. Move the cursor over the water and it leaves a trail of plankton light that flashes, lingers and drifts with the current. Type your address and every key sends a comet of light into the water; subscribe and a ring of light rolls out across the bay. 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 four ES modules. The sea is one WebGL canvas behind the page. Every word, the field and the button stay real DOM, so the form fills in, autocompletes and works with a screen reader like any other.
1 · The page, in HTML and CSS
Start with an ordinary form: a labelled email field and a Subscribe button. The button already holds its spinner and its tick, hidden, so the later states never change the layout.
<form class="field" id="sub" novalidate aria-labelledby="eyebrow">
<label class="sr" for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" inputmode="email" autocapitalize="off" autocorrect="off"
spellcheck="false" enterkeyhint="go" placeholder="mara@harbour.co" required aria-describedby="msg">
<button id="go" type="submit">
<span class="spin" aria-hidden="true"></span>
<svg class="tick" viewBox="0 0 20 20" aria-hidden="true"><path d="M4.5 10.5l3.6 3.6 7.4-8.2"/></svg>
<span class="lbl" id="go-lbl">Subscribe</span>
</button>
</form>
The desktop layout is the concept's 1600 × 1000 frame scaled to the window: one CSS unit, --u, is one
concept pixel, capped by the width and the height. The horizon sits at 40 % of the screen.
--u: min(.0625vw, .1svh);
--hz: 40svh; /* the horizon (the shader reads the .sky box) */
The field is a pill of frosted glass: a faint gradient, a backdrop blur, an inner rim and a deep shadow. Hover and focus only move two custom properties, the rim and the glow, so the transition is one line.
.field { --rim: rgba(150, 230, 255, .2); --glow: 0 0 0 rgba(40, 210, 255, 0);
position: relative; width: clamp(300px, calc(560 * var(--u)), 92vw); display: flex; gap: 8px; padding: max(6px, calc(8 * var(--u)));
border-radius: 999px; background: linear-gradient(180deg, rgba(150, 210, 240, .13), rgba(50, 110, 150, .08));
-webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
box-shadow: 0 1px 0 rgba(255, 255, 255, .14) inset, 0 0 0 1px var(--rim) inset, var(--glow), 0 20px 60px rgba(0, 0, 0, .45);
transition: box-shadow .35s var(--ease); }
.field:hover { --rim: rgba(160, 235, 255, .34); }
.field:focus-within { --rim: rgba(110, 235, 255, .6); --glow: 0 0 42px rgba(40, 210, 255, .26); }
.field button { position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: max(48px, calc(52 * var(--u))); min-width: max(120px, calc(122 * var(--u))); padding: 0 max(20px, calc(26 * var(--u))); border-radius: 999px; border: 0; cursor: pointer;
font: 600 max(14px, calc(15 * var(--u)))/1 'Inter'; color: #00222b; letter-spacing: .005em;
background: linear-gradient(180deg, #c9fbff, #5fe4f7 55%, #22bfe0);
box-shadow: 0 1px 0 rgba(255, 255, 255, .8) inset, 0 -2px 6px rgba(0, 90, 120, .25) inset, 0 0 26px rgba(60, 220, 255, .42);
transition: filter .2s, transform .15s var(--ease), box-shadow .25s; }
For now the sea is CSS: a night gradient with a bright horizon line, a moon, a striped glitter path and a band of blurred cyan where the shore break will be. The finished page keeps exactly this as its fallback, for a browser without WebGL or a lost GPU context.
.fallback { position: absolute; inset: 0; z-index: 0; display: none; pointer-events: none;
background:
radial-gradient(60% 40% at 83% calc(var(--hz-y, 40svh) - 20svh), rgba(150, 175, 205, .22), transparent 60%),
linear-gradient(180deg, #0a1220 0, #101b2d calc(var(--hz-y, 40svh) - 2px), #2a3d5c var(--hz-y, 40svh), #15233a calc(var(--hz-y, 40svh) + 2px), #08111f 70%, #050b16 100%); }
.fb-glow { position: absolute; left: -10%; right: -10%; top: calc(var(--hz-y, 40svh) + 37svh); height: 14svh; border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(80, 215, 240, .55), rgba(30, 140, 200, .18) 55%, transparent 75%); filter: blur(6px); }

2 · The sea you touch
The camera is one unit above a flat sea, looking at the horizon. Every pixel below the horizon turns into a point on the water, which is all the shaders need for perspective: ripples, the moon's reflection and the light get smaller and denser toward the horizon by themselves.
vec3 rayAt(vec2 fc){ return normalize(vec3((fc.x-uCx)/uF, (fc.y-uHz)/uF, 1.0)); }
The light lives in a small GPU texture, one texel per two CSS pixels, drawn back and forth between two half-float targets. Each frame it drifts with a slow current, spreads into its neighbours and fades: the red channel is the flash (gone in about a second), blue the bright core that lingers, green a wide dim halo.
c=mix(c,n,clamp(uDt*(0.8+2.2*k),0.0,0.5));
c.rgb*=exp(-uDt*vec3(1.25,0.32,0.36));
c.rgb=max(c.rgb-uEps*uDt,0.0);
Strokes arrive as uniforms, never as uploads: up to sixteen capsules a frame, each with a radius, a flash and an afterglow. A pixel takes the brightest capsule near it.
for(int i=0;i<16;i++){
if(float(i)>=uNSeg) break;
float d=segD(p,uSeg[i].xy,uSeg[i].zw); float r=uSegP[i].x;
float core=exp(-d*d/(r*r));
fl=max(fl,uSegP[i].y*core);
ac=max(ac,uSegP[i].z*exp(-d*d/(0.7*r*r)));
ag=max(ag,uSegP[i].z*0.55*exp(-d*d/(5.0*r*r)));
}
The field is only the amount of light. What you see is that light shaped by the water: lace, grain and wave crests from a ripple atlas baked once at start-up, so the light looks like plankton in moving water and not like a brush.
float L=G.r*(0.22+0.8*lace+0.5*grain+0.3*crest)*(0.55+0.6*brk)
+ G.b*(0.16+0.55*lace+0.75*grain+0.25*crest)
+ G.g*(0.07+0.3*lace+0.12*crest);
The focused field wakes a pool of light under itself, dimmer and rose-tinted when the address is wrong.
if(uFocus>0.001){
vec2 q=(fc-vec2(uField.x,uField.y-uField.w*0.35))/vec2(uField.z*1.1,uField.w*1.1);
float pool=exp(-dot(q,q)*2.2)*(0.15+0.9*lace+0.6*grain);
L+=pool*uFocus*mix(0.16,0.04,uErr);
}
That shading costs a dozen texture reads a pixel, so it runs at half resolution and only below the horizon. The sky is drawn once per resize and never again; a full-resolution pass adds only what has to be sharp: the moon glitter and the sparks.
// 2. water lighting, half resolution, below the horizon only
const H = this.half, hy = Math.min(H.h, Math.ceil(this.hzPx / 2) + 2);
bind(gl, H, 0, 0, H.w, hy);
Every pointer move over the water becomes a capsule from the last point to this one, wider near the viewer and brighter the faster you move. Coalesced events keep a fast flick smooth; a finger counts while it is down.
function onMove(e) {
if (!st.L) return;
const now = performance.now();
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
const list = evs.length ? evs : [e];
for (const ev of list) {
const x = ev.clientX - st.left, y = ev.clientY + scrollY - st.top;
if (y < st.L.hz + 4) { st.last = null; continue; }
if (st.last) {
const [lx, ly, lt] = st.last, d = Math.hypot(x - lx, y - ly);
if (d < 1.5) continue;
const v = d / Math.max(4, (ev.timeStamp || now) - lt) * 1000;
const k = depth((y + ly) / 2), touch = e.pointerType === 'touch';
const r = (touch ? 12 : 8) * (0.32 + 0.9 * k) * Math.max(1, st.u * (st.portrait ? 0.8 : 1));
const s = Math.min(1, v / 1100);
stroke(lx, ly, x, y, r, 0.38 + 0.62 * s, 0.2 + 0.3 * s);
}
st.last = [x, y, ev.timeStamp || now];
}
st.lastPointer = now;
wake();
}
Light never goes behind the words: a stroke that touches the form or the readers line is skipped, against boxes measured once per layout, not per event.
function stroke(x0, y0, x1, y1, r, flash, after) {
if (!st.L || y1 < st.L.hz + 2 || y0 < st.L.hz + 2) return;
if (overUI(x0, y0) || overUI(x1, y1)) return;
st.queue.push([x0, y0, x1, y1, r, flash, after]); st.strokes++;
st.lastInject = performance.now();
}
The form is real: an empty or unfinished address gets a rose rim, a small shake and a message; a mistyped domain gets a one-tap fix; then a busy state, and the welcome. Nothing is sent anywhere in this demo, and the address is never stored.
form.addEventListener('submit', (e) => {
e.preventDefault();
if (st.state === 'busy' || st.state === 'done' || input.disabled) return;
const v = input.value.trim();
if (!v) return showError('Add your email address to get the Sunday letter.');
if (!VALID.test(v)) return showError('That address looks unfinished — try name@domain.com');
const dom = v.split('@')[1].toLowerCase();
if (TYPOS[dom] && st.pendingTypo !== v) {
st.pendingTypo = v;
const fixed = v.split('@')[0] + '@' + TYPOS[dom];
fixBtn.textContent = `Use ${fixed}`; fixBtn.dataset.v = fixed; fixBtn.hidden = false;
setMsg(`Did you mean <b>${TYPOS[dom]}</b>? Press Subscribe again to keep <b>${dom}</b>.`);
return;
}
st.pendingTypo = null; fixBtn.hidden = true;
st.state = 'busy'; btn.setAttribute('aria-busy', 'true'); input.readOnly = true; lbl.textContent = 'Sending…';
setTimeout(succeed, Q.has('fast') ? 50 : 900);
});
The loop runs at full rate only while something moves fast, ticks a 30 fps tide when the water is calm, and after a quiet spell it lets the sea ease to a stop and sleeps. The renderer keeps an exact upper bound of the field's brightness, so the page knows the water is dark without reading a pixel back.
function fast(now) {
return st.queue.length || now - st.lastInject < 2200 || now - st.lastPointer < 600
|| Math.abs(st.focus - focusTarget()) > 0.002 || st.err > 0 || (st.speed > 0 && st.speed < 1);
}
// the sea's own clock: eases to a stop after a quiet spell once the water is dark (or always, with reduced motion)
const want = RM.matches || (quiet && !lit) ? 0 : 1;
if (want > st.speed) st.speed = Math.min(1, st.speed + dt / 0.8); else if (want < st.speed) st.speed = Math.max(0, st.speed - dt / 1.6);
// exact bound: decay then the linear floor; injections saturate at 2.0
this.glowMax = Math.max(0, this.glowMax * Math.exp(-S.dt * K_AFTER) - this.eps * S.dt);
if (peak > 0) this.glowMax = Math.min(2, this.glowMax + peak);

3 · The finish
Now the water answers the form. Every keystroke launches a comet of light from under the caret: it dives into the water on a curve and rings where it settles. One ring per key blurred into a blob; a comet reads as each letter landing.
// a comet of plankton light dives from under the caret into the water, curling, and rings where it settles
const side = Math.random() < 0.5 ? -1 : 1, len = (60 + 80 * Math.random()) * s, dx = side * (40 + 100 * Math.random()) * s;
st.comets.push({ x0: x, y0: y, cx: x - dx * 0.6, cy: y + len * 0.45, x1: x + dx, y1: y + len, t: 0, dur: 0.5 + 0.25 * Math.random(), last: null, r: 2.5 * s });
st.comets = st.comets.filter((c) => {
c.t = Math.min(1, c.t + dt / c.dur); const q = 1 - Math.pow(1 - c.t, 1.8), m = 1 - q;
const p = [m * m * c.x0 + 2 * m * q * c.cx + q * q * c.x1, m * m * c.y0 + 2 * m * q * c.cy + q * q * c.y1];
if (c.last) stroke(c.last[0], c.last[1], p[0], p[1], c.r * (0.6 + 0.6 * depth(p[1])) * (1 - 0.4 * c.t), 0.62 * (1 - 0.5 * c.t), 0.26, true);
c.last = p;
if (c.t >= 1) ring(p[0], p[1], { r0: 4, r1: 30 * Math.max(1, st.u), w0: 1.6, w1: 3.5, life: 0.9, flash: 0.22, after: 0.04 });
return c.t < 1;
});
Subscribing blooms the bay: a splash of light under the field, the shore flaring and a flash washing up the sky. The travelling ring is drawn in the water shader, not painted into the field, so it never leaves a filled disc behind.
function bloom() {
if (!st.L) return;
const f = fieldBox(), y = f.bottom + 26 * Math.max(1, st.u);
st.bloom = { x: f.cx, y, age: 0 }; st.lift = 1;
stroke(f.cx - 1, y, f.cx + 1, y, 40 * Math.max(1, st.u), 0.8, 0.35, true);
// the travelling ring itself is analytic (WATER_FS): a ring painted into the field would leave a filled disc behind it
flashEl.style.setProperty('--fy', (y / st.L.h * 100).toFixed(1) + '%');
flashEl.classList.remove('on'); void flashEl.offsetWidth; flashEl.classList.add('on');
wake();
}
if(uBloom.w>0.0){
vec3 brd=rayAt(uBloom.xy); vec3 bpos=brd*(-1.0/min(brd.y,-0.02));
float rr=length((pos.xz-bpos.xz)*vec2(1.0,1.5));
float a=uBloom.z;
float ring=exp(-pow((rr-a*3.8)/(0.12+a*0.16),2.0))*exp(-a*0.75);
float inner=exp(-pow((rr-a*2.4)/(0.18+a*0.2),2.0))*exp(-a*1.1)*0.5;
L+=((ring*(0.15+1.2*lace+0.9*grain+0.4*crest)+inner*(0.1+0.8*lace+0.6*grain))*1.0+exp(-rr*3.0)*exp(-a*1.6)*1.1)*uBloom.w;
}
A first visit opens with a showcase: the shore break ignites from left to right and two curls draw themselves through the water, as if an invisible hand showed what the page does.
float front=uIntro*1.35-0.1;
float ign=1.0-smoothstep(front-0.18,front,sc.x);
shore*=ign*(1.0+1.6*exp(-pow((sc.x-front+0.06)/0.06,2.0))*step(uIntro,0.999));
function feedCurl(c, p) {
const n = Math.floor(p * (c.pts.length - 1));
while (c.i < n) {
const a = c.pts[c.i], b = c.pts[c.i + 1];
stroke(a[0], a[1], b[0], b[1], a[2], 1.0 * a[3], 0.95 * a[3]);
c.i++;
}
}
While the page is awake and nobody is touching it, a fish passes under the surface now and then, leaving a faint streak that fades at the idle rate.
function fish(now) {
if (RM.matches || !st.L || now < st.nextFish || now - st.lastInput > SLEEP_AFTER - 6000 || now - st.lastPointer < 2500) return;
st.nextFish = now + 6500 + Math.random() * 4000;
const { w, hz, vh } = st.L;
const y = hz + vh * (0.2 + 0.25 * Math.random()), x = w * (0.12 + 0.76 * Math.random()), dir = Math.random() < 0.5 ? -1 : 1;
const len = (90 + 80 * Math.random()) * Math.max(1, st.u), k = depth(y);
let px = x, py = y;
for (let i = 1; i <= 7; i++) {
const nx = x + dir * len * i / 7, ny = y + Math.sin(i * 0.9) * 6 * k;
stroke(px, py, nx, ny, 9 * (0.4 + 0.8 * k) * Math.max(1, st.u), 0.42, 0.22, false, true); px = nx; py = ny;
}
}
If the GPU context is lost, the page switches to the CSS sea from stage 1, and comes back when the context returns. With reduced motion there is no intro, the sea's clock stands still and the loop sleeps.
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); sea = null; root.classList.add('no-fx'); });
canvas.addEventListener('webglcontextrestored', () => { sea = startSea(); if (sea) { root.classList.remove('no-fx'); layout(); wake(); } });


Make it yours
- Your words are ordinary HTML: the headline, the lede, the readers line. The form needs an
actionor afetchto your mail provider where the demo waits 900 ms. - Your water is a handful of numbers: the fade rates in
GLOW_FS(how long light lingers), the stroke radius and flash inonMove, the bloom's speed in the water shader. - Accessibility is already there: every word is DOM, a real label, errors announced and marked
aria-invalid,aria-busywhile sending, visible focus, 44 px targets,prefers-reduced-motionhonoured, the loop asleep when nothing moves, and a CSS sea without WebGL.
Noctiluca is a made-up newsletter. Everything on the page was made in code for this project: the sky, the moon, the sea and the light are shaders.
Keep the light as a small field of numbers on the GPU and shape it with the water only when you draw it. The field is cheap to write into (a capsule per pointer move, a ring per key) and cheap to fade, and the water shading is what makes it look like plankton rather than paint.