shvedov.tech
← Back to writing

Live backgrounds for web page

I wanted to put some quiet little detail on this site — without burning a weekend on it.

The idea: animate the page background in a way that makes the reader feel they’re nudging what happens.

That’s how the Aurora was born — a colour drift built entirely on CSS, no animation library, no <canvas>.

There’s a ✦ tweak aurora button in the bottom-right corner. Open it, play with the knobs, then come back here if you get curious about what’s happening under the hood.

A little code

addEventListener('scroll', () => {
  document.documentElement.style.setProperty('--scroll-px', String(scrollY));
}, { passive: true });
@property --scroll-px {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

body::before {
  content: "";
  position: fixed;
  inset: 0;
  background-image: radial-gradient(closest-side, oklch(82% 0.07 200), transparent);
  background-repeat: no-repeat;
  background-position: 50% 50%;
  background-size:
    calc(20% + (sin(var(--scroll-px) * 0.22deg) + 1) * 0.5 * 320%)
    calc(20% + (cos(var(--scroll-px) * 0.20deg) + 1) * 0.5 * 320%);
}

Why trig?

A sine wave is the smoothest possible wave that endlessly rocks between −1 and +1. No sharp corners, no repeated values inside a cycle. Multiply it, add to it, shift it around — whatever you do, the output stays smooth.

(sin + 1) / 2 shifts the result into the [0, 1] range: add one to clear the negatives, divide by two to fit. Without this background-size gets unhappy — it doesn’t accept negative values.

Independence from page length

You could also reach for animation-timeline: scroll(root). It works, but its timeline is bound to the page’s height — on a short page the animation flies by, on a long one it barely moves.

Here we drive things by raw scroll distance instead. Scroll the same amount, get the same amount of motion, no matter how tall the page itself is.

Different sources — drivers

A driver is just a number that changes. Anything that produces a number qualifies:

  • ScrollscrollY. Default. Aurora responds to reading.
  • Time — a requestAnimationFrame loop incrementing a counter. Always animating, regardless of input. Good for ambient backgrounds.
  • MousemousemovemouseX/Y. Gradient follows the cursor.
  • Scroll velocity — the derivative of scrollY. Aurora “settles” when the page is still and “stretches” when you scroll fast.
  • Audio levelAnalyserNode.getByteFrequencyData()--bass, --treble. Backgrounds that pulse with music.
  • Network speed, weather, time of day — anything you can stream into a number.

The playground has three options — scroll / time / mouse. Switch to time and the background animates on its own. Switch to mouse and move your cursor around. Same math; only the value fed into --scroll-px changes.

Different waveforms

Sin and cos aren’t the only options:

  • Sum of sines — quasi-periodic, drifting. The sum of two button in the playground.
  • Modulated sinesin(t · ω₁) · (1 + 0.3 · cos(t · ω₂)). Amplitude itself wobbles — “breathing within breathing”.
  • Squared sinepow(sin(t · ω), 2). Always positive, double frequency, sharp peaks; reads as a heartbeat.
  • Absolute valueabs(sin(t · ω)). Similar shape but with a hard reflection at zero — feels less natural.
  • tan() — has vertical asymptotes; useful only with clamping. Mostly a curiosity.
  • Hand-crafted polynomial(1 - t) · A + t · B for a single ramp. Combine many for a piecewise feel.

What each knob does

A short rundown of the panel.

driver

  • scroll--scroll-px is read from scrollY. Default.
  • time — a counter incremented every frame. Endless slow animation.
  • mousemouseX × 3 + mouseY × 4. The further the cursor from the top-left, the larger the phase.

motion

  • Speed (×) — global multiplier on the driver value. = twice the response to any input.
  • Ease (0.02–1) — smoothing factor (low-pass). 0.02 is heavy inertia, visible lag on sharp scrolls. 1 is instant, no smoothing, may strobe on flicks.
  • Freq scale (×) — scales every ω in the formulas. Changes the cycle length. 0.5× = cycles twice as long (slower).

shape

  • Size min (%) — the floor of blob inflation. Blobs never shrink below this.
  • Size max (%) — inflation amplitude. Added to the floor to get the peak size. 500% = a blob 5× the viewport.
  • Position amp (%) — how far blobs may drift from their base positions.

colour

  • Color amount (%) — the percentage in color-mix(in oklch, <color> <amount>%, transparent). How vividly each colour shows up.
  • Opacity (0.05–1) — opacity of the whole aurora layer.
  • Hue shift (°) — static rotation of the whole palette via filter: hue-rotate().
  • Hue drift (°/px) — how much the hue rotates per unit of driver input. 0.04°/px = one full rotation per 9000 scroll pixels.

formula

  • single sin — each axis driven by one wave. Pure Lissajous → the orbit is visible.
  • sum of two — each axis = sum of two waves at different frequencies. Non-rational ratios → quasi-periodic, the path never closes.

reset

Snaps all values back to defaults and clears the inline overrides on :root.

Smoothing the input

Raw scrollY is jagged. Trackpad flicks, Page Down, momentum scrolls — any of them can dump a thousand pixels in a single frame. Feed that straight into the formula and the gradient rips through five sine cycles in a blink. Strobes.

The simplest fix is to smooth it out. Each frame, ease the current value toward the target by a small fraction of the difference:

let current = scrollY;

function tick() {
  const target = scrollY;
  current += (target - current) * 0.085;
  root.style.setProperty('--scroll-px', current);
  if (Math.abs(target - current) > 0.3) requestAnimationFrame(tick);
}

Lower factor → more lag, smoother. Higher → snappier. As a bonus, this kind of smoothing encodes velocity for free: a fast scroll gives a long catch-up tail, a slow one almost none.

The cost

Worth remembering: an animated background is work. On weaker hardware it will warm the CPU and drain the battery noticeably. More layers and heavier filters (blur, backdrop-filter) make it worse. So if you want pretty — add a little at a time and test on something old.

Take it with you

Here’s a self-contained HTML — download it, open it in a browser, see how it all fits together. If you like something, take it with you.

aurora-example.html — inside: a <style> with the formulas, a <script> with the driver, and enough lorem ipsum to give you something to scroll. Open it in your editor — everything is in plain sight and commented.