GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Aurora Background CSS: 9 Living Night-Sky Scenes

Aurora background css in canvas. One gradient gives an aurora that sits like wallpaper.

Nine Night-Sky Scenes to Stir on a Dark Stage

Auto-plays · click a tile to jump to its section · all nine in one zip

Each scene is an animated night sky meant to sit behind a page headline, with no image files, all computed: a canvas works out every band of light, star and ripple frame by frame. Left alone, every cell keeps running by itself, and its pause button stops it whenever you like. In 05 and 09 a virtual pointer with a fixed seed keeps moving: in 05 it loops along a path woven from two sine waves of different periods, and in 09 a single sine sweeps it left and right. Rest your own pointer on the stage and the scene follows you, then drifts back to the virtual path once you leave. 08 runs a 2,600ms demo loop that turns the dial to 9 at 200ms and down to 2 at 1,300ms, and the slider handle moves along with it.

The order swaps the drawing method one layer at a time. 01 strokes vertical lines as vectors with no buffer at all, 02 paints a similar band cell by cell in a small pixel buffer, and 03 fills that buffer with noise whose sampling coordinates are bent first. 04 reuses a finished sky upside down as a reflection, 05 trades painting for 900 dust grains carried by a flow field, and 06 samples the aurora strength again at each star. 07 averages that strength under the headline box and turns it into scrim opacity, 08 hands a value you choose to a spring, and 09 joins the 01 curtain and the 04 reflection with mountain ridges in one hero.

Aurora Background CSS: 9 Effects — Noise Curtain, Vertical Rays, Warped Sky, Lake Reflection, Pointer Wind, Star Occlusion, Headline Scrim, Intensity Dial and a Ridge Hero

All nine scenes are drawn with the 2D canvas and plain JavaScript. The CSS side holds zero @keyframes and zero filter: blur rules, and there is no library, WebGL, CDN or image file in the set. Time arrives through requestAnimationFrame, but the gap it reports is cut into fixed 16ms ticks, at most 6 per frame or 100ms of catch-up, and the noise reads the accumulated simMs divided by 1,000 as its time coordinate in seconds. The canvas pixel ratio stops at 2 through Math.min(window.devicePixelRatio || 1, 2).

Changed pixels across the stage, compared 23 times

Each scene was captured as 24 frames over two seconds and every neighboring pair was compared, which gives 23 comparisons. Cumulative area is the share of the stage where pixels changed at any point in that window, moved frames counts the comparisons that found a difference, and the last column is the mean change intensity the render tool reported.

Scene Cumulative area Moved frames Intensity avg
01 Noise curtain 36.2% 23/23 22.4
02 Vertical rays 26.7% 23/23 17.2
03 Warped sky 67.9% 23/23 18.4
04 Lake reflection 43.7% 23/23 16.2
05 Pointer wind 71.0% 23/23 53.1
06 Star occlusion 34.8% 23/23 22.4
07 Headline scrim 69.5% 23/23 27.8
08 Intensity dial 36.0% 23/23 26.2
09 Ridge hero 41.3% 23/23 18.3

Every scene scored 23/23, so the picture differed at each comparison. 05 leads with 71.0% and 53.1, likely because its 900 grains spread over the whole stage and their trails lose half their light in 7.3 ticks, so brightness shifts almost everywhere. 02 is the smallest at 26.7%, which appears to come from its rays lighting up only inside a 55-row band of a 100-row buffer while the dark sky above stays put.

From a cabin booking page to a weather widget: where each scene fits

For a full first screen, look at 01, 03 or 09. The first curtain layer in 01 keeps its centerline low, between 213 and 232px on a 300px stage, which leaves dark sky above for a product headline; 03 suits a company landing hero where soft color patches should blend with no edges; 09 fills a travel landing page top to bottom with ridges, sky and lake. 04 belongs on a lakeside cabin booking page, 06 on an event page for a stargazing night, and 02 on a music release page where light streaks carry the rhythm.

One rule runs through the set: text is not drawn on the canvas. The 07 headline, "New wireless earbuds, revealed tonight", is real text inside <p data-role="headline">, place() seats its box as percentages of the stage, and the canvas sits behind it with aria-hidden="true". If you turn 08 into a night-sky weather widget, writing a fetched value into levelTarget in place of the range input should let the same spring carry it.

Why Does a Single Aurora Gradient Look Like Frozen Wallpaper?

A gradient can only move where its colors sit

A typical aurora gradient background css recipe decides up front where each color sits, and the animation can only slide, rotate or recolor that whole blob. The grain inside the blob stays the same, so after a few seconds the eye reads one pattern gliding past, which is likely where the wallpaper feeling comes from. A band of night-sky light needs a crisp lower hem, vertical streaks that fade as they climb, and folds that keep forming anew. Each of those needs its own value at every horizontal position and every moment, and this set gets those values from noise.

How this set differs from three earlier auroras

Scene 03 in CSS Background Animation is a color blob made of three radial-gradient layers with filter: blur(3px) and a spin keyframe, and scene 01 in Mesh Gradient CSS, Grain Left In on Purpose is a section decoration of five radial-gradient layers, blur(28px) and a drift keyframe. Scene 05 of the canvas noise terrain set painted an aurora band with octave-summed noise on a canvas, but as a single item. Both earlier posts are a sound aurora animation css choice for a decorative section; this one uses no keyframes or blur and splits the same noise ingredients nine ways instead, from curtain and rays to reflection, flow, occlusion and contrast.

Stacking and Twisting Noise — Octave Sums, Domain Warping and a Swirling Flow Field for Bands of Light

From an integer grid hash to an octave sum

There is one raw ingredient. hash2 hands every integer grid point a value from 0 to 1, and noise2 blends the four corner values of the cell a point falls in along a 3t²−2t³ curve, producing smooth value noise. octaves adds n layers of that noise, each at twice the frequency and half the weight, then divides by the total weight, so broad swells carry small ripples and the result stays between 0 and 1. The 01 centerline takes its height from a 3-layer sum, while fold brightness reads a 2-layer sum at a 2.6× finer grain, stretches what lies above a 0.25 threshold through a smooth step and raises it to the power 1.8 to split bright folds from dark gaps.

Moving where noise is read, and turning a slope sideways

Domain warping in 03 leaves noise values untouched and moves the point where noise is read. It reads two values q, shifts p by 1.6q and reads two values r there, then shifts p by 1.6r for the final read: five octave sums of 3 octaves each, or 15 noise calls per pixel, and across a 120×75 buffer of 9,000 cells that is 135,000 calls per frame. In a headless check at development time (2026-10-09), one paintWarp call took 2.07ms.

05 takes the horizontal and vertical partial derivatives of a potential ψ and rotates them 90 degrees to use as velocity. Following the slope directly would herd dust toward peaks or hollows, but the rotated flow only circles along contour lines, so what flows into a spot roughly matches what flows out. Measured, the swirl field's mean divergence is 2.27e-06 against 0.013 for the plain slope of the same potential, about 5,700 times smaller, and that gap appears to be why the dust does not clump or leave empty holes as it travels.

Paint a small buffer, then stretch it and add it on

The scenes that write one value per cell, 02, 03, 06, 07 and 08, keep small buffers whatever the screen size. makeBuffer prepares an offscreen canvas and a createImageData block, and blitBuffer uploads it with putImageData, then stretches it onto the 480×300 stage with imageSmoothingQuality = 'high' and 'lighter' compositing (the createImageData reference). Buffer sizes are 160×100 for 02, 120×75 for 03, 07 and 08, and 96×60 for 06. None of the nine calls getImageData to read the screen back, so even 07 works out brightness from values it painted itself.

01An Aurora Curtain That Sways on Layered Noise

Each of the three curtain layers draws a vertical line every 3px with its foot on a centerline set by an octave sum, fading upward by exp(-RAY_FALLOFF × d) so brightness halves every 38.5px. Every line is a vector stroke filled with its own vertical gradient, so no pixel buffer is involved, and at the 165px tip the alpha factor is down to 0.051, letting the top of the curtain melt into the sky. The first layer's centerline moved at most 20.1px in one second, calm enough for a hero that stays on screen for a long visit.

An Aurora Curtain That Sways on Layered Noise
Open in a new tab

01 · An Aurora Curtain That Sways on Layered Noise — Measured area 36.213% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CURTAIN_LAYERSNOISE_SCALERAY_FALLOFF
        for (let layer = 0; layer < CURTAIN_LAYERS; layer += 1) {
            const k = CURTAIN_LAYERS > 1 ? layer / (CURTAIN_LAYERS - 1) : 0;
            const layerAlpha = o.alpha * (1 - 0.35 * k);
            for (let x = -STROKE_STEP; x <= w + STROKE_STEP; x += STROKE_STEP) {
                const fold = curtainFold(o, layer, x, t);
                if (fold < 0.02) {
                    continue;
                }
                const yc = curtainCenter(o, layer, x, t);
                const len = o.len * (0.55 + 0.75 * fold) * (1 - 0.25 * k);
                const top = yc - len;
                const g = c.createLinearGradient(0, yc + 6, 0, top);
                g.addColorStop(0, rgba(o.low, 0));
                // bottom edge 6px — a short bright rise keeps the curtain hem crisp
                g.addColorStop(6 / (len + 6), rgba(o.low, layerAlpha * fold));
                for (let s = 1; s <= 4; s += 1) {
                    const d = len * s / 4;
                    g.addColorStop((6 + d) / (len + 6), rgba(mix(o.low, o.high, s / 4), layerAlpha * fold * Math.exp(-RAY_FALLOFF * d)));
                }
                c.fillStyle = g;
                c.fillRect(x, top, STROKE_STEP + 0.6, len + 6);
            }
        }

02Vertical Rays Computed Pixel by Pixel

Each of the 16,000 cells in a 160×100 buffer uses its column's light strength times a height gradient as alpha, and the buffer is stretched 3× onto the stage, so needle-thin rays brighten and fade column by column. Column strength mixes a fast and a slow noise, pushes the sum past a threshold and squares it, while a sine running at 0.6 to 1.4× speed per column presses brightness down to as low as 0.85×. In the 5.2-second frame 151 of the 160 columns are lit, a good match for a page that wants the whole band filled with streaks of light.

Vertical Rays Computed Pixel by Pixel
Open in a new tab

02 · Vertical Rays Computed Pixel by Pixel — Measured area 26.675% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHADER_WIDTHRAY_FLICKERBAND_HEIGHT
    function rayColumn(col, t) {
        const fast = noise2(col * RAY_GRAIN, t * 0.7, 211);
        const slow = noise2(col * RAY_GRAIN * 0.12 + t * 0.12, 3.5, 223);
        const lit = Math.pow(smooth((fast * 0.6 + slow * 0.7 - 0.42) / 0.5), 2);
        const flick = 1 - RAY_FLICKER * (0.5 + 0.5 * Math.sin(t * FLICKER_SPEED * colRate[col] + colPhase[col]));
        return lit * flick;
    }
    // lower band edge (buffer row) — ripples slowly along the columns
    function rayBottom(col, t) {
        return SHADER_HEIGHT * (0.8 + (noise2(col * 0.035 + t * 0.15, 7.5, 229) - 0.5) * 0.22);
    }
    // height gradient — dies out within 2 cells below the edge, fades as (1 - u)^1.6 over the band height above it
    function heightProfile(row, bottom) {
        const band = BAND_HEIGHT * SHADER_HEIGHT;
        const d = bottom - row;
        if (d < 0) {
            return Math.max(0, 1 + d / 2);
        }
        const u = d / band;
        return u >= 1 ? 0 : Math.pow(1 - u, 1.6) * (0.55 + 0.45 * Math.exp(-d / 3));
    }

03A Soft Sky That Flows by Warping Noise With Noise

warped shifts the coordinates it reads by q and then by r, each scaled by 1.6, so the grain swirls instead of drifting one way. A weighted sum of that value, the horizontal part of r and time picks a color from a 6-color table, and since time adds 0.05 per second the colors come full circle every 20 seconds. Edgeless patches of light cover 67.9% of the stage as they blend slowly, a fit for a landing hero where color should change quietly.

A Soft Sky That Flows by Warping Noise With Noise
Open in a new tab

03 · A Soft Sky That Flows by Warping Noise With Noise — Measured area 67.861% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WARP_STRENGTHWARP_OCTAVESHUE_DRIFT
    function warped(x, y, t, out) {
        const f = t * WARP_FLOW;
        const qx = fbm(x + f, y, 301);
        const qy = fbm(x + 5.2, y + 1.3 - f, 302);
        const rx = fbm(x + WARP_STRENGTH * qx + 1.7 - f * 0.7, y + WARP_STRENGTH * qy + 9.2, 303);
        const ry = fbm(x + WARP_STRENGTH * qx + 8.3, y + WARP_STRENGTH * qy + 2.8 + f * 0.8, 304);
        out[0] = qx;
        out[1] = rx;
        return fbm(x + WARP_STRENGTH * rx, y + WARP_STRENGTH * ry, 305);
    }
    // palette lookup — interpolates a 0..1 position between two neighboring colors in the table; past 1 it wraps to the start
    function paletteAt(u) {
        const n = PALETTE_03.length;
        const p = (((u % 1) + 1) % 1) * n;
        const i = Math.floor(p);
        return mix(PALETTE_03[i % n], PALETTE_03[(i + 1) % n], smooth(p - i));
    }

04A Lake That Mirrors the Sky With a Ripple per Row

Below the horizon at 176px, 124px of water is cut into 62 rows of 2px, and each row redraws its mirror row of sky pushed sideways by the sum of two sines. Amplitude grows from 0.25× to 1× with depth, so the shift peaks at 1.09px by the shore and 4.30px at the bottom of the frame, and two translucent copies spread to either side are layered in to blur the reflection more the deeper the water gets. The sky is drawn once on an offscreen canvas, shown above and reused in the water, so a lakeside cabin page does not pay for the sky twice.

A Lake That Mirrors the Sky With a Ripple per Row
Open in a new tab

04 · A Lake That Mirrors the Sky With a Ripple per Row — Measured area 43.742% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_AMPRIPPLE_FREQREFLECT_DIM
        for (let y = 0; y < waterH; y += REFLECT_ROW) {
            const depth = y / waterH;
            // perspective — nearer (lower) water gets bigger, looser ripples
            const amp = RIPPLE_AMP * (0.25 + 0.75 * depth);
            const ph = y * RIPPLE_FREQ * (1 - 0.45 * depth);
            const dx = amp * Math.sin(ph + t * RIPPLE_SPEED) + amp * 0.45 * Math.sin(ph * 2.3 - t * RIPPLE_SPEED * 1.4);
            // mirror — the row y below the shoreline reflects the sky row y above it
            const sy = clamp(horizon - REFLECT_ROW - y, 0, horizon - REFLECT_ROW);
            c.globalAlpha = 0.88 - 0.3 * depth;
            c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx, horizon + y, W, REFLECT_ROW);
            // blur — the deeper the row, the more two side-shifted translucent copies are layered on
            const spread = 1 + 4 * depth;
            c.globalAlpha = 0.22 * depth;
            c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx - spread, horizon + y, W, REFLECT_ROW);
            c.drawImage(src, 0, sy * sd, src.width, REFLECT_ROW * sd, dx + spread, horizon + y, W, REFLECT_ROW);
        }

05Light Dust That Flows in the Wind Your Pointer Stirs

Each of 900 dust grains moves at a velocity built from the potential's partial derivatives, sampled 1.5px apart in each direction and rotated 90 degrees, and near the pointer a wind of pointer speed times 2.4 times a distance falloff is added. The wind drops to 0.779× at 40px from the pointer, 0.368× at 80px and 0.018× at 160px, and each tick lays a black veil at alpha 0.09 before adding the dust on top, so trails linger like ribbons. With the stage focused, each arrow key sends a 24px gust, which makes it a good portfolio intro where first-time visitors should feel invited to touch something.

Light Dust That Flows in the Wind Your Pointer Stirs
Open in a new tab

05 · Light Dust That Flows in the Wind Your Pointer Stirs — Measured area 71.011% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWIRL_SCALEPOINTER_WINDDUST_COUNT
    function potential(x, y, t) {
        return octaves(x * SWIRL_SCALE, y * SWIRL_SCALE + t * 0.05, 2, 501);
    }
    // swirl flow field — v = (∂ψ/∂y, -∂ψ/∂x). It follows the potential contour lines, so divergence is 0 — dust does not bunch up or empty out
    function flow(x, y, t, out) {
        const e = CURL_EPS;
        const dPdy = (potential(x, y + e, t) - potential(x, y - e, t)) / (2 * e);
        const dPdx = (potential(x + e, y, t) - potential(x - e, y, t)) / (2 * e);
        out[0] = dPdy / SWIRL_SCALE;
        out[1] = -dPdx / SWIRL_SCALE;
    }

06Twinkling Stars That Hide When the Aurora Passes

Each of the 140 stars twinkles on a sine with its own phase and a 0.6 to 1.6× speed, a cycle of 1.78 to 4.76 seconds, and is multiplied by 1 - AURORA_OCCLUSION × a, with the aurora strength a recomputed at its spot, so a fully covered star drops to 0.15×. The 31 stars with a radius over 1.5px carry cross-shaped rays whose length follows their brightness. At 5 seconds the 11 stars inside the band average a brightness factor of 0.441, so this one suits a stargazing event page that wants a veil of light drawn over a star-led sky.

Twinkling Stars That Hide When the Aurora Passes
Open in a new tab

06 · Twinkling Stars That Hide When the Aurora Passes — Measured area 34.751% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAR_COUNTTWINKLE_SPEEDAURORA_OCCLUSION
    function drawStars06(t) {
        for (let i = 0; i < STAR_COUNT; i += 1) {
            const tw = 0.5 + 0.5 * Math.sin(t * TWINKLE_SPEED * starRate[i] + starPhase[i]);
            const a = auroraAt(starX[i], starY[i], t);
            const vis = (0.3 + 0.7 * tw) * (1 - AURORA_OCCLUSION * a);
            if (vis < 0.02) {
                continue;
            }
            ctx.fillStyle = rgba(STAR_RGB, vis);
            ctx.beginPath();
            ctx.arc(starX[i], starY[i], starR[i], 0, TAU);
            ctx.fill();
            // big stars get cross rays — their length shrinks and grows with brightness too
            if (starR[i] > 1.5) {
                const L = starR[i] * 4 * vis;
                ctx.fillStyle = rgba(STAR_RGB, vis * 0.5);
                ctx.fillRect(starX[i] - L, starY[i] - 0.4, L * 2, 0.8);
                ctx.fillRect(starX[i] - 0.4, starY[i] - L, 0.8, L * 2);
            }
        }
    }

07A Scrim Behind the Headline That Darkens by Measured Brightness

While painting its buffer, the scene averages the cells under the 340×76px headline box, and every 3 ticks (48ms) neededAlpha runs 14 rounds of binary search for the alpha at which that color mixed with the scrim ink falls below a target luminance of 0.1142. The scrim's real alpha closes 0.12 of the remaining gap each tick and reaches 63% in 8 ticks (128ms), and brightness comes from its own buffer values plus the sky formula instead of a screen read. Over 8 seconds, contrast that would have sunk to 3.06:1 without the scrim held at 5.04:1 at worst and 7.78:1 on average, useful when a launch headline has to sit on a moving sky.

A Scrim Behind the Headline That Darkens by Measured Brightness
Open in a new tab

07 · A Scrim Behind the Headline That Darkens by Measured Brightness — Measured area 69.529% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SCRIM_TARGET_RATIOSCRIM_ALPHA_MAXSCRIM_SMOOTH
    function neededAlpha(avg) {
        const goal = (luminance(TEXT_RGB) + 0.05) / SCRIM_TARGET_RATIO - 0.05;
        if (luminance(avg) <= goal) {
            return 0;
        }
        let lo = 0;
        let hi = 1;
        for (let k = 0; k < 14; k += 1) {
            const mid = (lo + hi) / 2;
            if (luminance(mix(avg, SCRIM_INK, mid)) > goal) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        return Math.min(hi, SCRIM_ALPHA_MAX);
    }
    function itemStep(dt) {
        tickNo += 1;
        if (tickNo % SCRIM_EVERY === 0) {
            scrimTarget = neededAlpha(boxAvg);
        }
        scrimAlpha += (scrimTarget - scrimAlpha) * SCRIM_SMOOTH;
    }

08A Light Storm That Springs Up as You Raise the Intensity Dial

The <input type="range"> value from 0 to 10, divided by 10, becomes the target of a spring with stiffness 60 and damping 7, and the spring's value sets band height (30px at 0, 165px at 1), flow speed and hue shift (up to 0.35) together. With a damping ratio of 0.452, a step from 0.2 to 0.9 first enters the ±2% band at 288ms, overshoots by 19.2% to 1.035 at 432ms and stays inside the band for good from 1,040ms. Speed piles up in flowPhase, so a sudden twist of the dial does not make the pattern jump, which fits a night-sky weather widget that shows the mood of the sky with one number.

A Light Storm That Springs Up as You Raise the Intensity Dial
Open in a new tab

08 · A Light Storm That Springs Up as You Raise the Intensity Dial — Measured area 36.038% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STORM_LEVEL_DEFAULTLEVEL_STIFFNESSHUE_SHIFT_MAX
    // spring — x is the current intensity (0..1, may overshoot), v is velocity
    const lvl = { x: STORM_LEVEL_DEFAULT / LEVEL_MAX, v: 0 };
    let levelTarget = lvl.x;
    // flow phase — accumulates speed x time (the pattern does not jump when speed changes)
    let flowPhase = 0;
    function springStep(dt) {
        const s = dt / 1000;
        const a = -LEVEL_STIFFNESS * (lvl.x - levelTarget) - LEVEL_DAMPING * lvl.v;
        lvl.v += a * s;
        lvl.x += lvl.v * s;
    }
    function flowSpeed(x) { return lerp(0.25, 2.2, clamp(x, 0, 1.2)); }
    function bandHeight08(x) { return lerp(30, 165, clamp(x, 0, 1.25)); }
    function hueShift08(x) { return HUE_SHIFT_MAX * clamp(x, 0, 1.2); }

09A Parallax Hero of Mountain Ridges, Aurora and Lake Reflection

Each of the three ridge layers fixes its two end heights, then nudges every midpoint off its neighbors' average, scaling the nudge by 0.55 per round over 6 rounds to reach 65 points. The parallax offset chases a pointer-set target by 8% per tick and takes 576ms (36 ticks) to cover 95% of the way, with layers shifting up to 6, 14 and 26px so nearer mountains move more. The 01 curtain, 70 stars and the ridges go onto a sky canvas that the 04 drawReflection then mirrors in the water, ridges included, so one scene can carry a whole travel landing page.

A Parallax Hero of Mountain Ridges, Aurora and Lake Reflection
Open in a new tab

09 · A Parallax Hero of Mountain Ridges, Aurora and Lake Reflection — Measured area 41.267% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIDGE_LAYERSRIDGE_ROUGHNESSPARALLAX_LERP
    function midpointRidge(rng, amp) {
        const n = RIDGE_POINTS;
        const a = new Float32Array(n);
        a[0] = (rng() - 0.5) * amp;
        a[n - 1] = (rng() - 0.5) * amp;
        let step = n - 1;
        let spread = amp;
        while (step > 1) {
            const half = step / 2;
            for (let i = half; i < n; i += step) {
                a[i] = (a[i - half] + a[i + half]) / 2 + (rng() - 0.5) * spread;
            }
            step = half;
            spread *= RIDGE_ROUGHNESS;
        }
        return a;
    }
    const ridges = [];
    for (let k = 0; k < RIDGE_LAYERS; k += 1) {
        ridges.push(midpointRidge(rng09, RIDGE_AMP[k]));
    }

Huge Screens, Bright Headlines, Slow Devices — Where a Per-Pixel Aurora Breaks

A target of 6 and a measured low of 5 to 1

In 07 the target contrast is SCRIM_TARGET_RATIO 6, yet the lowest value measured over 8 seconds (500 ticks) was 5.04:1. The target alpha is set afresh every 3 ticks and the real alpha follows only 12% of the gap per tick, so when a bright band slides quickly into the headline box the scrim darkens a beat late. Across those 8 seconds the peak alpha was 0.348, and 15.6% of ticks had an alpha of 0.3 or more. Raising the follow rate SCRIM_SMOOTH would shorten the lag but would likely make the scrim flutter with every passing band, so this build leans toward smoothness.

The average is a weak spot of its own. Alpha comes from the mean color of the whole box, so a thin bright streak crossing only one edge of it keeps the mean low, and the letters on that streak appear to sit below the target. There is a cap as well, SCRIM_ALPHA_MAX 0.82. With the current colors even a white background needs only 0.657 and a pure mint one 0.602, but pushing the target ratio much higher or darkening the text would likely run into the cap and miss the goal.

Growing a buffer to full stage resolution costs 64×

Growing the 03 buffer from 9,000 cells to the stage's real 480×300 at a pixel ratio of 2, or 576,000 cells, multiplies the cell count by 64, and scaling the development-time 2.07ms by that gives roughly 130ms per frame. That cannot fit inside a 16ms tick, which is why 02, 03, 06, 07 and 08 keep their buffers small, paying for it with blur on large screens. Edgeless patches like 03 hide the blur well, but the needle rays in 02 may show their 3× wider columns.

The download archive carries the index.html for each of the nine scenes, curtain strokes, pixel buffers, row reflections and the scrim included, plus React versions built from the same source, and it opens with 85c5k85f.

Hundreds of gradients and three draws per reflection row

01 builds a fresh createLinearGradient for every line whose fold brightness is at or above 0.02, out of 489 candidates (3 layers × 163 positions), which comes to 446 gradients in the 5.2-second frame and anywhere from 241 to 489, 361.7 on average, between 0.016 and 60 seconds. On slow devices those objects likely add garbage collection load, and raising STROKE_STEP from 3 cuts the count but thins the curtain. The 04 reflection calls drawImage three times per 2px row, which over its 124px of water comes to 186 calls per frame. 09 runs its water through the same function, but its horizon sits lower at 196px, so its 104px of water takes 156 calls, and blowing either display up a lot may expose row-sized steps.

Trail smudges and arrow keys that go quiet

05 clears its trails by laying black at alpha 0.09 every tick, and on an 8-bit canvas dark values appear to stall above 0 because of rounding, which can leave faint smudges. When the pointer comes back in from outside the stage, the jump is clipped by WIND_CAP 12 so the wind does not spike. Arrow keys in 05 and 09 respond only while the stage itself has focus; with focus on the pause button the stage ignores them.

Three Variations: Finer Noise Grain, Stronger Ray Flicker, Bigger Ripples

Variation Value Feel
Finer noise grain 01 NOISE_SCALE 0.004 → 0.012 One noise cell shrinks from 250px to 83px, the first centerline bends 14 times across the stage instead of 5, and its mean slope rises from 0.045 to 0.246, so lazy folds turn into a fine, flickering flame
Stronger ray flicker 02 RAY_FLICKER 0.15 → 0.5 The lowest factor the flicker presses rays down to falls from 0.85 to 0.50, and the 2-second relative standard deviation of lit columns rises from 0.450 to 0.538, so each needle blinks faster on its own
Bigger ripples 04 RIPPLE_AMP 3 → 10 The largest row shift grows from 1.09px at the shore and 4.30px at the bottom to 3.62px and 14.32px, breaking the crisp curtain reflection into heavy waves

Raising the flicker more than threefold lifts the relative standard deviation by only about 20%, likely because slow noise already drives most of a lit column's brightness change. Flicker is one multiplied layer on top, so widening its range from 0.85 to 1 into 0.50 to 1 adds only a modest share of the total movement. 04 scales in step with amplitude, 3.3× at the shore and at the bottom alike, which makes it the easiest of the three knobs to predict in numbers.

One Still Frame Under Reduced Motion — Accessibility That Keeps the Headline Readable

One clock stopped at 5,200ms freezes every scene together

When prefers-reduced-motion: reduce is on, none of the nine scenes start the animation loop, and each draws one frame with its clock set to STILL_MS, 5,200ms (the prefers-reduced-motion reference). Flicker in 02, ripples in 04 and 09 and twinkling in 06 read that same clock, so they stop at the same instant, and if the setting changes while the page is open, a change listener freezes the scene right there. The pause button is hidden in this mode. In a headless check, 01, 05, 08 and 09 produced identical frames 300ms apart.

Input still lands on a frozen screen

Moving the 08 dial skips the spring and draws the target strength straight away, 09 sets the parallax at its target at once, and 05 computes 24 ticks and redraws a single frame. 01, 02, 03, 04, 06 and 07 have no control to operate, so only the still frame remains.

Range input, pause button and stage focus

The 08 control is a real <input type="range"> from 0 to 10 with a <label> reading "Light intensity", not a fake handle painted on the canvas, so arrow keys, Home and End change the value with the browser's default behavior (the range input reference). Pause is a <button> with aria-pressed, and pressing it changes the label from "Pause motion" to "Resume motion". In 05 and 09 the stage takes focus through tabindex="0" and role="group", and arrow keys move the wind source 24px in four directions in 05 or shift the view 30px left and right in 09. Focus rings are drawn in cream on :focus-visible, the status line starts as aria-live="off" and switches to polite on the first input, and the canvas in all nine scenes carries aria-hidden="true".

Aurora Borealis CSS Background on a Real Site: Common Questions

Can an aurora borealis effect css build skip the canvas entirely?

For color blobs that turn or drift slowly, CSS is enough: Mesh Gradient CSS, Grain Left In on Purpose builds that kind of section decoration from radial-gradient, blur and keyframes alone. A curtain with a crisp hem, rays that flicker column by column and a reflection that wobbles row by row each need a different value per position and moment, so they take computation; this set draws them on a canvas, and the canvas scene effects collection works the same way.

The scenes look soft on a large monitor, so can the buffer be bigger?

It can, but the work grows with the cell count. Doubling 03's 120×75 in both directions gives 4× the cells, which at development-time figures would likely push one paint past 8ms. Scenes stroked as vectors without a buffer, like the 01 curtain, render at the full pixel ratio up to 2, so they are a sensible pick for a large-screen hero.

Can the 07 scrim be lifted onto a different background?

neededAlpha takes only the mean color under the box, so the function moves over as is. 07, though, computes that mean from its own buffer and sky formula instead of reading the screen, so on another background you need to work out the average color that background paints under the box and pass it in. If you do not paint the background yourself, an extra step that reads the screen and averages it would likely be needed.

Enter the archive password

The password is inside this article. You will find it as you read.