Library / Tutorial

Glow sign-up,
step by step.

A newsletter sign-up over a moonlit sea. The cursor and a finger leave trails of plankton light in the water, every keystroke sends a comet of light under the field, and subscribing blooms the whole bay. Every word stays DOM. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

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.

index.html — the form
<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.

glow.css — the frame unit and the horizon
--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.

glow.css — the glass pill
.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); }
glow.css — hover and focus
.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); }
glow.css — the Subscribe button
.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.

glow.css — the CSS night sea
.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%); }
glow.css — the shore glow
.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); }
Stage 1 · HTML and CSSOpen ↗
The Noctiluca page in plain HTML and CSS: the headline The sea writes back. over a starless CSS night with a round moon, the glass email field with a cyan Subscribe button on a frosted gradient sea, and a soft cyan band where the shore will glow.

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.

shaders.js — a ray for every pixel
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.

shaders.js — drift, spread and fade
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.

shaders.js — strokes into the field
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.

shaders.js — light shaped by the water
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.

shaders.js — the pool under the field
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.

sea.js — half resolution, scissored to the water
// 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.

app.js — the pointer
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.

app.js — a stroke
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.

app.js — submit
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.

app.js — when to run at full rate
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);
}
app.js — easing the sea to a stop
// 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);
sea.js — an exact bound on the light
// 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);
Stage 2 · the sea you touchOpen ↗
The page with the WebGL night sea: a round moon with a halo and a glittering path on rippled water, the lit shore break across the foreground, and a fresh loop of cyan plankton light the cursor has just drawn under the form.

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.

app.js — a comet per key
// 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 });
app.js — drawing the comets
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.

app.js — the bloom
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();
}
shaders.js — the ring rolling across the bay
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.

shaders.js — the shore igniting
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));
app.js — a curl, fed along its path
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.

app.js — a passing fish
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.

app.js — context lost and restored
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(); } });
Stage 3 · the finished sign-upOpen ↗
The finished sign-up: mara@tide.co typed into the glass field, comets of cyan light diving from under the caret into the water, a loop of light around the bay and the glowing shore break under the moon.
Stage 3 · on a phoneOpen ↗
The sign-up on a phone: The sea writes back. on one line, the glass field with a full-width Subscribe button under it, the readers line, and a loop of plankton light a finger has just drawn through the water above the shore break.

Make it yours

  • Your words are ordinary HTML: the headline, the lede, the readers line. The form needs an action or a fetch to 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 in onMove, 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-busy while sending, visible focus, 44 px targets, prefers-reduced-motion honoured, 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.

The one idea worth keeping

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.