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:
- Scroll —
scrollY. Default. Aurora responds to reading. - Time — a
requestAnimationFrameloop incrementing a counter. Always animating, regardless of input. Good for ambient backgrounds. - Mouse —
mousemove→mouseX/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 level —
AnalyserNode.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 twobutton in the playground. - Modulated sine —
sin(t · ω₁) · (1 + 0.3 · cos(t · ω₂)). Amplitude itself wobbles — “breathing within breathing”. - Squared sine —
pow(sin(t · ω), 2). Always positive, double frequency, sharp peaks; reads as a heartbeat. - Absolute value —
abs(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 · Bfor a single ramp. Combine many for a piecewise feel.
What each knob does
A short rundown of the panel.
driver
- scroll —
--scroll-pxis read fromscrollY. Default. - time — a counter incremented every frame. Endless slow animation.
- mouse —
mouseX × 3 + mouseY × 4. The further the cursor from the top-left, the larger the phase.
motion
- Speed (×) — global multiplier on the driver value.
2×= twice the response to any input. - Ease (0.02–1) — smoothing factor (low-pass).
0.02is heavy inertia, visible lag on sharp scrolls.1is 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.