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

CSS Starfield: 9 Backgrounds Stars Fly Through

A css starfield of a few CSS-animated dots has no depth and a flat constant speed — scenes 01, 02 and 05 solve it with projection, layer offsets and an

Fly Through All Nine Star Backgrounds Right on This Page

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

CSS Starfield: 9 Backgrounds Where Depth, Rotation and Refraction Are Computed

One thing first: the nine scenes are not CSS keyframes. Every star is drawn by canvas 2D and JavaScript, while CSS keeps the 480 by 300 frame, the caption and button spots, the focus outline and touch-action: none. Hundreds of stars each need a position divided by their own distance, and that division belongs in code that runs every frame rather than in a keyframe list.

All nine files share one start() body, identical from line 60 to line 197 when hashed. It steps scene.step() in FIXED_DT 16 ms slices, falls back to a scripted curve after IDLE_TICKS 150 (2.4 seconds) without a pointer, and draws random numbers from a seeded lcg instead of Math.random, so a reload seats every star where it was. The seeds run from 450101 in scene 01 to 450909 in scene 09.

Scene Input it reads Core calculation
01 Warp streaks pointer = vanishing point, tap = speed burst projection divided by depth z
02 Three parallax layers pointer = parallax direction, tap = flip the flow shift divided by layer depth
03 Noise night sky pointer x = noise origin, tap = seed +1 two layers of value noise
04 Color temperature pointer = vanishing point, tap = flip the turn cosine of the view angle
05 Jump tap = restart the jump power-of-two speed curve
06 Sky dome pointer = head turn, tap = rotate 60° yaw and pitch rotation matrices
07 Constellations pointer = one star, tap = redraw the speeds grid-cell neighbor search
08 Shooting stars tap = fire one from the pointer spot position history and gravity
09 Gravity lens pointer = lens center, tap = mass ×1.9 point-mass lens equation

Scene 05 never reads pointer movement, and 08 uses the pointer only at the moment of a tap. A grep for Math.random, Date and fetch returns 0 hits in all nine files, and there is no library, CDN or WebGL, so each page needs nothing from outside.

Code for Each Technique Behind a CSS Starfield Background

Every snippet is a run of consecutive lines cut from one scene's index.html, and none is longer than 25 lines. The whole files, start to finish, sit in the archive further down.

01Stars That Stretch into Warp Streaks as They Near

Each star holds an (x, y, z) position and lands on screen at the vanishing point vx, vy plus x·FOV÷z. The streak joins the star's current z to a z farther away by speed·STREAK_LEN, so speed is literally the streak length, and a star below Z_NEAR 24, or 80px past the screen edge, is placed again at Z_FAR 1000. The render logged 57.2% cumulative area and 23/23 moved frames.

Stars That Stretch into Warp Streaks as They Near
Open in a new tab

01 · Stars That Stretch into Warp Streaks as They Near — Measured area 57.23% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOVSPEEDSTREAK_LENSTAR_COUNT
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    s.z -= speed;
                    const sx = vx + s.x * FOV / s.z;
                    const sy = vy + s.y * FOV / s.z;
                    if (s.z < Z_NEAR || sx < -80 || sx > W + 80 || sy < -80 || sy > H + 80) {
                        place(s, true);
                    }
                }
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    const k = 1 - s.z / Z_FAR;
                    const zb = Math.min(s.z + speed * STREAK_LEN, Z_FAR * 1.3);
                    ctx.strokeStyle = rgba(s.c, 0.25 + 0.75 * k);
                    ctx.lineWidth = 0.6 + 2.6 * k * k;
                    ctx.beginPath();
                    ctx.moveTo(vx + s.x * FOV / zb, vy + s.y * FOV / zb);
                    ctx.lineTo(vx + s.x * FOV / s.z, vy + s.y * FOV / s.z);
                    ctx.stroke();
                }

02Three Star Layers Drifting at Different Depth Speeds

A layer moves by px·PARALLAX÷depth + drift÷depth, and with depths 4, 2 and 1, a pointer pushed to the end shifts the front layer 70px and the farthest 17.5px. Radius falls as DEPTH_SCALE÷depth^0.8 and brightness as 0.35 + 0.65÷√depth, so far layers are smaller and dimmer and only depth 1 gets a halo. The render logged 52.6% cumulative area and 23/23 moved frames.

Three Star Layers Drifting at Different Depth Speeds
Open in a new tab

02 · Three Star Layers Drifting at Different Depth Speeds — Measured area 52.597% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LAYER_COUNTPARALLAXDRIFTDEPTH_SCALE
                for (let li = 0; li < LAYER_COUNT; li += 1) {
                    const d = LAYER_DEPTH[li];
                    const ox = px * PARALLAX / d + drift / d;
                    const oy = py * PARALLAX * 0.6 / d;
                    const ms = mists[li];
                    for (let i = 0; i < ms.length; i += 1) {
                        glow(ctx, wrap(ms[i].x - ox, W + 160) - 80, wrap(ms[i].y - oy, H + 120) - 60, ms[i].r, ms[i].c, 0.07 + 0.05 / d);
                    }
                    const dots = layers[li];
                    const alpha = 0.35 + 0.65 / Math.sqrt(d);
                    for (let i = 0; i < dots.length; i += 1) {
                        const s = dots[i];
                        const sx = wrap(s.x - ox, W);
                        const sy = wrap(s.y - oy, H);
                        if (d === 1) {
                            glow(ctx, sx, sy, s.r * 4, s.c, 0.3);
                        }
                        ctx.fillStyle = rgba(s.c, alpha);
                        ctx.beginPath();
                        ctx.arc(sx, sy, s.r, 0, PI2);
                        ctx.fill();
                    }
                }

03A Night Sky Where a Noise Field Twinkles Each Star Differently

The brightness of 360 stars is a slow value noise (coordinates × NOISE_SCALE 0.011) blended 0.65 to 0.35 with a fast one (coordinates × 0.5), raised to CONTRAST 2.6, so neighbors glow together while distant stars flicker alone. The haze behind is a 60 by 38 image filled with the same noise and stretched to 480 by 300, which makes this the only scene using createImageData. The render logged 69.4% cumulative area and 23/23 moved frames.

A Night Sky Where a Noise Field Twinkles Each Star Differently
Open in a new tab

03 · A Night Sky Where a Noise Field Twinkles Each Star Differently — Measured area 69.392% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

NOISE_SCALETWINKLE_SPEEDCONTRASTSEED
    // Value at a grid point: two integers and the seed mixed into 0 up to (not including) 1
    function hash2(ix, iy, seed) {
        let h = Math.imul(ix | 0, 374761393) + Math.imul(iy | 0, 668265263) + Math.imul(seed | 0, 1442695041);
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
    }
    // 2D value noise: the four grid points blended smoothly
    function vnoise(x, y, seed) {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = smooth(x - ix);
        const fy = smooth(y - iy);
        return lerp(lerp(hash2(ix, iy, seed), hash2(ix + 1, iy, seed), fx), lerp(hash2(ix, iy + 1, seed), hash2(ix + 1, iy + 1, seed), fx), fy);
    }
                    // Mix the slow field (neighbors look alike) and the fast one (differs per star) into brightness
                    const slow = vnoise(s.x * NOISE_SCALE + ox * 0.05, s.y * NOISE_SCALE + phase, seed);
                    const fast = vnoise(s.x * 0.5 + phase * 7, s.y * 0.5, seed + 1);
                    const v = slow * 0.65 + fast * 0.35;
                    const b = clamp(Math.pow(v * 1.6, CONTRAST), 0, 1);

04Starlight That Shifts Blue Ahead and Orange Off-Axis with Speed

For a star at screen distance r from the vanishing point, the cosine of its view angle is FOV÷√(r²+FOV²), and that feeds a color temperature k, white-blue in the middle and drifting to orange toward the rim. k starts at COLOR_TEMP 0.5 and moves by SHIFT_GAIN·(cosA − CONE_COS)·(0.5 + speed ratio), and since the ratio rises and falls on a sine, the tilt breathes with it. The render logged 62.4% cumulative area and 23/23 moved frames.

Starlight That Shifts Blue Ahead and Orange Off-Axis with Speed
Open in a new tab

04 · Starlight That Shifts Blue Ahead and Orange Off-Axis with Speed — Measured area 62.376% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLOR_TEMPSHIFT_GAINHEADINGCONE_COS
    // 0 is orange, 0.5 is white, 1 is blue-white
    function temperature(k) {
        return k < 0.5 ? mix(WARM, WHITE, k * 2) : mix(WHITE, COOL, (k - 0.5) * 2);
    }
                    const sx = vx + s.x * FOV / s.z;
                    const sy = vy + s.y * FOV / s.z;
                    const r = Math.hypot(sx - vx, sy - vy);
                    // Cosine of the angle between the view and the star direction: smaller farther from the vanishing point
                    const cosA = FOV / Math.hypot(r, FOV);
                    const k = clamp(COLOR_TEMP + SHIFT_GAIN * (cosA - CONE_COS) * (0.5 + ratio), 0, 1);
                    const c = temperature(k);

05A Jump Curve from Standing Still to Light Speed

One cycle is 170 ticks (2.72 seconds), split into 18 waiting, 72 accelerating, 24 flashing and 56 settling, and speed climbs from BASE_SPEED to MAX_SPEED by (2^(JUMP_EASE·p) − 1)÷(2^JUMP_EASE − 1). Streak length comes from the same speed, and the flash alpha is FLASH·(1 − q)², so one curve ties all three together. The render logged 75.4% cumulative area and 23/23 moved frames.

A Jump Curve from Standing Still to Light Speed
Open in a new tab

05 · A Jump Curve from Standing Still to Light Speed — Measured area 75.433% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

JUMP_EASEMAX_SPEEDFLASHHOLD
    // Speed at tick t. Only the climb follows the power-of-two curve; the rest is a smooth return
    function speedAt(t) {
        if (t < HOLD) {
            return BASE_SPEED;
        }
        if (t < HOLD + RAMP) {
            const p = (t - HOLD) / RAMP;
            return BASE_SPEED + (MAX_SPEED - BASE_SPEED) * (Math.pow(2, JUMP_EASE * p) - 1) / (Math.pow(2, JUMP_EASE) - 1);
        }
        if (t < HOLD + RAMP + FLASH_TICKS) {
            const q = (t - HOLD - RAMP) / FLASH_TICKS;
            return lerp(MAX_SPEED, BASE_SPEED, smooth(q));
        }
        return BASE_SPEED;
    }
    function flashAt(t) {
        const q = (t - HOLD - RAMP) / FLASH_TICKS;
        if (q < 0 || q > 1) {
            return 0;
        }
        return FLASH * (1 - q) * (1 - q);
    }

06A Sky Dome of Stars on a Sphere Turned by a Rotation Matrix

900 stars sit evenly on a sphere and 520 more are gathered around a tilted great circle for a bright band across the sky, then project multiplies a yaw matrix and a pitch matrix and plots W/2 + FOV·x÷z. A point with z at or below NEAR_CUT 0.08 is behind the view and skipped, and the 5 latitude lines and 10 meridians are dotted so the turning direction shows. The render logged 56.9% cumulative area and 23/23 moved frames.

A Sky Dome of Stars on a Sphere Turned by a Rotation Matrix
Open in a new tab

06 · A Sky Dome of Stars on a Sphere Turned by a Rotation Matrix — Measured area 56.859% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

YAW_RATEPITCHFOVSTAR_COUNT
        function project(x, y, z, cy, sy, cp, sp) {
            const x1 = x * cy + z * sy;
            const z1 = -x * sy + z * cy;
            const y2 = y * cp - z1 * sp;
            const z2 = y * sp + z1 * cp;
            return [W / 2 + FOV * x1 / z2, H / 2 - FOV * y2 / z2, z2];
        }
                for (let i = 0; i < stars.length; i += 1) {
                    const s = stars[i];
                    const p = project(s.x, s.y, s.z, cyw, syw, cp, sp);
                    if (p[2] <= NEAR_CUT || p[0] < -6 || p[0] > W + 6 || p[1] < -6 || p[1] > H + 6) {
                        continue;
                    }
                    const r = 0.5 + 1.9 * s.m * s.m * s.m + (i < STAR_COUNT ? 0.3 : 0);
                    const a = 0.35 + 0.65 * clamp(p[2] * 1.2, 0, 1);
                    if (r > 1.5) {
                        glow(ctx, p[0], p[1], r * 5, s.c, 0.35 * a);
                    }
                    ctx.fillStyle = rgba(s.c, a);
                    ctx.beginPath();
                    ctx.arc(p[0], p[1], r, 0, PI2);
                    ctx.fill();
                }

07Constellations Where Nearby Stars Join by Lines

110 stars and one pointer star are filed by number into GRID_CELL squares (72px, equal to LINK_RADIUS), and each star scans only the 3×3 cells around its own, drawing a line to any partner closer than 72. Line alpha is LINE_ALPHA·(1 − d÷LINK_RADIUS)^1.3, and a star takes at most 3 lines (the pointer 6). The render logged 56.2% cumulative area and 23/23 moved frames.

Constellations Where Nearby Stars Join by Lines
Open in a new tab

07 · Constellations Where Nearby Stars Join by Lines — Measured area 56.17% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LINK_RADIUSMAX_LINKSGRID_CELLLINE_ALPHA
                for (let i = 0; i < nodes.length; i += 1) {
                    nodes[i].n = 0;
                    const cx = clamp(Math.floor(nodes[i].x / GRID_CELL), 0, cols - 1);
                    const cy = clamp(Math.floor(nodes[i].y / GRID_CELL), 0, rows - 1);
                    cells[cy * cols + cx].push(i);
                }
                    for (let gy = Math.max(0, cy - 1); gy <= Math.min(rows - 1, cy + 1); gy += 1) {
                        for (let gx = Math.max(0, cx - 1); gx <= Math.min(cols - 1, cx + 1); gx += 1) {
                            const list = cells[gy * cols + gx];
                            for (let q = 0; q < list.length; q += 1) {
                                const j = list[q];
                                const b = nodes[j];
                                if (j <= i || a.n >= cap) {
                                    continue;
                                }
                                const capB = j === NODE_COUNT ? CURSOR_LINKS : MAX_LINKS;
                                if (b.n >= capB) {
                                    continue;
                                }
                                const d = Math.hypot(a.x - b.x, a.y - b.y);
                                if (d >= LINK_RADIUS) {
                                    continue;
                                }
                                a.n += 1;
                                b.n += 1;
                                const k = 1 - d / LINK_RADIUS;
                                ctx.strokeStyle = rgba(mix(a.c, b.c, 0.5), LINE_ALPHA * Math.pow(k, 1.3));

08Shooting Stars Whose Curved Tails Fade as They Fall

Every tick a meteor puts its position at the front of a history array, kept to TRAIL_LEN 44 joints, where joint alpha is (1 − age)^1.6 and width is 6·(1 − age) + 0.8. GRAVITY 0.06 is added to the vertical speed each tick so the path bends, and covering the background at alpha 0.8 only leaves the afterglow. The render logged 24.5% cumulative area and 23/23 moved frames.

Shooting Stars Whose Curved Tails Fade as They Fall
Open in a new tab

08 · Shooting Stars Whose Curved Tails Fade as They Fall — Measured area 24.526% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAIL_LENMETEOR_RATEANGLEGRAVITY
                for (let i = meteors.length - 1; i >= 0; i -= 1) {
                    const m = meteors[i];
                    if (m.live) {
                        m.hist.unshift(m.x, m.y);
                        m.vy += GRAVITY;
                        m.x += m.vx;
                        m.y += m.vy;
                        if (m.x > W + 70 || m.y > H + 70) {
                            m.live = false;
                        }
                    }
                    else {
                        m.hist.pop();
                        m.hist.pop();
                    }
                    if (m.hist.length > TRAIL_LEN * 2) {
                        m.hist.length = TRAIL_LEN * 2;
                    }
                    if (!m.live && m.hist.length === 0) {
                        meteors.splice(i, 1);
                    }
                }
                    for (let k = 0; k < n; k += 1) {
                        const age = k / TRAIL_LEN;
                        const a = Math.pow(1 - age, 1.6);
                        const c = mix(HEAD, TAIL, age);
                        const hx = m.hist[k * 2];
                        const hy = m.hist[k * 2 + 1];
                        ctx.strokeStyle = rgba(c, 0.22 * a);
                        ctx.lineWidth = (7 * (1 - age) + 1) * 2.2;
                        ctx.beginPath();
                        ctx.moveTo(px, py);
                        ctx.lineTo(hx, hy);
                        ctx.stroke();
                        ctx.strokeStyle = rgba(c, a);
                        ctx.lineWidth = 6 * (1 - age) + 0.8;
                        ctx.beginPath();
                        ctx.moveTo(px, py);
                        ctx.lineTo(hx, hy);
                        ctx.stroke();
                        px = hx;
                        py = hy;
                    }

09A Gravity Lens That Bends Starlight into a Ring Near a Black Hole

A star at distance b from the lens center forms an image at (b + √(b²+4M))÷2 and another on the far side at (√(b²+4M) − b)÷2, so with LENS_MASS 2600 a star at b 0 smears into a ring of radius √2600, about 51px. Only star coordinates move while the background stays put, and SOFTEN 5 keeps the magnification formula from dividing by zero at b 0. The render logged 30.9% cumulative area and 23/23 moved frames.

A Gravity Lens That Bends Starlight into a Ring Near a Black Hole
Open in a new tab

09 · A Gravity Lens That Bends Starlight into a Ring Near a Black Hole — Measured area 30.934% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LENS_MASSRING_RADIUSFALLOFFSOFTEN
        function image(ctx, bx, by, r, c, m, alpha) {
            const b = Math.hypot(bx, by);
            const bs = Math.sqrt(b * b + SOFTEN * SOFTEN);
            const root = Math.sqrt(bs * bs + 4 * m);
            const thP = (bs + root) / 2;
            const thM = (root - bs) / 2;
            const u = bs / Math.sqrt(m);
            const mag = (u * u + 2) / (2 * u * Math.sqrt(u * u + 4));
            const muP = clamp(mag + 0.5, 1, 9);
            const muM = clamp(mag - 0.5, 0, 9);
            const ux = b > 0.001 ? bx / b : 1;
            const uy = b > 0.001 ? by / b : 0;
            const ang = Math.atan2(uy, ux) + Math.PI / 2;
            const stretchP = clamp(thP / bs, 1, 6);
            ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muP, FALLOFF) * 0.8, 0.3, 1));
            ctx.beginPath();
            ctx.ellipse(cx + ux * thP, cy + uy * thP, r * stretchP, r * 0.8, ang, 0, PI2);
            ctx.fill();
            if (muM > 0.04) {
                const stretchM = clamp(thM / bs, 0.4, 6);
                ctx.fillStyle = rgba(c, alpha * clamp(Math.pow(muM, FALLOFF) * 0.8, 0, 0.9));
                ctx.beginPath();
                ctx.ellipse(cx - ux * thM, cy - uy * thM, r * Math.max(stretchM, 0.8), r * 0.7, ang, 0, PI2);
                ctx.fill();
            }
        }

Where to Lay Each Star Background — Hero, Loader, Network Backdrop

Each scene has its own seat. A landing hero takes the warp of 01 or the dome of 06, a backdrop that should feel deep under pointer or scroll takes the three layers of 02, a still night sky that needs breath takes 03, and a space or science-fiction palette takes 04. A loader or intro transition fits the jump of 05, which cycles every 170 ticks, so stretching HOLD and RAMP to the loading time is enough; a network backdrop takes 07, an event or mood backdrop 08, and a single focal point on the screen takes 09.

Before putting a js starfield behind real content, note that the work per frame differs by scene. Scene 06 calls project 2,110 times a frame (1,420 stars plus 690 latitude and meridian dots), and scene 03 calls vnoise 7,560 times (3 per pixel over 2,280 haze pixels and 2 per star over 360 stars). Frame time was not timed for this page, so run any scene you plan to leave behind a page on your own screen first.

A few scenes overlap with other canvas pages on this site. For stars that pull on one another, continue with N Body Simulation, 9 Ways (Galaxy, No Library), while scene 09 here keeps the stars still and computes only the lens. Scenes 01 and 07 of HTML5 Canvas Animation: 9 Warp & Droplet Effects read pixels with getImageData, while its black hole tunnel in 05 does not; none of these nine files calls it, and only star coordinates move. For particles that drift with scrolling, see Moving Particles Background CSS: 9 Scroll Scenes.

Where the Values Change the Sense of Speed — Speed, Link Radius, Lens Mass

Three places turn a whole scene with one value: SPEED in 01, LINK_RADIUS in 07 and LENS_MASS in 09. The figures below come from running the same source for 200 ticks in node with a fake 2D context and counting on one frame, so they are simulation values, not browser measurements.

Doubling the speed more than doubles the streaks

At SPEED 12 there are 280 streak segments with a mean length of 5.0px. At 24 the count stays 280 and the mean grows to 11.4px, so twice the speed lengthens a streak by more than twice.

A wider link radius barely adds lines

At LINK_RADIUS 72, 158 lines are drawn with a mean length of 47.3px. Widening to 110 gives 161 lines, almost unchanged, and only the mean length grows to 80.7px, which shows the MAX_LINKS cap of 3 per star holding the count in place.

Doubling the lens mass raises the ring radius by 1.41

At LENS_MASS 2600 the ring radius is 51px with 454 images drawn. At 5200 the radius is 72px with 510 images, which fits a radius of √M growing about 1.41 times when the mass doubles.

Below are the render numbers for all nine scenes. Moved frames are 23/23 in every scene, cumulative area is smallest in 08 (24.5%) and 09 (30.9%) and largest in 05 (75.4%) and 03 (69.4%).

Scene Cumulative area Mean intensity Moved frames
01 Warp streaks 57.2% 47.3 23/23
02 Parallax layers 52.6% 49.8 23/23
03 Noise night sky 69.4% 23.0 23/23
04 Color temperature 62.4% 35.4 23/23
05 Jump curve 75.4% 65.1 23/23
06 Sky dome 56.9% 50.1 23/23
07 Constellations 56.2% 41.8 23/23
08 Shooting stars 24.5% 68.4 23/23
09 Gravity lens 30.9% 83.1 23/23

Where It Breaks — High Star Counts, Narrow Screens, Reduced-Motion Settings

Close stars throw streaks across the whole screen

In 01, 04 and 05 x·FOV÷z grows as a star approaches. In 01 a star with SPREAD 760 reaching Z_NEAR 24 would be plotted about 6017px from the vanishing point, and the check that calls place(s, true) once it is past the screen edge is what stops that (Z_NEAR 24 and 80px in 01, 22 and 80px in 04, 20 and 100px in 05). Remove that check and the Z_NEAR test and a single line can eat a frame.

Pushing the layers makes stars pop out on the other side

In 02 the shift is divided by depth alone, so a larger PARALLAX moves a front-layer star past W in one step and wrap returns it on the opposite side. Multiplying time by the flow direction would cause a jump, which is why dir multiplies only what is added into the running drift.

Changing the seed rewrites the sky in one frame

A tap in 03 raises seed by 1 and the hash changes completely, so neighboring seeds give skies that do not resemble each other (it is not an interpolation). The haze is a 60 by 38 image stretched, so at devicePixelRatio 2 it smears about 16 times in each direction, and createImageData runs once per frame.

The view angle is approximated by screen distance

In 04 even the rim at r 240 only drops the cosine to 0.53 (about 58°). Anything behind the viewer (past 90°) is not handled, which is why the status text only promises blue-white ahead and orange at the rim, and about 145px from the vanishing point is where the cosine hits 0.72 (CONE_COS) and starlight turns white.

A jump pressed mid-flash is cut off

In 05 with JUMP_EASE 6, at p = 0.5 only 11% of the climb is done ((2³−1)÷(2⁶−1) = 0.111), a speed of about 9.2 against a top of 70, so most of the climb looks like standing still before it shoots up late. A tap sets t back to HOLD from any phase, so pressing during the flash ends it abruptly.

The wide angle stretches stars at the rim

The FOV 150 in 06 is a wide angle that pulls rim stars longer. One full turn takes 2π÷0.012, about 524 ticks (8.4 seconds), yaw keeps accumulating so a long run keeps feeding large numbers to trigonometry, and the still frame under reduced motion shows the dot grid faintly.

Which line survives depends on the star number

In 07 the star drawn first fills a MAX_LINKS slot, so while stars move, which lines remain depends on their numbers and lines can seem to blink. Making GRID_CELL smaller than LINK_RADIUS misses partners that sit beyond the neighboring cells.

The afterglow never returns to pure black

Scene 08 covers the background at alpha 0.8 only, so a dark residue stays at the 8-bit rounding limit. The trail is an array filled with unshift, so a larger TRAIL_LEN costs more copying every tick, and overlapping round line caps make a tail look like beads.

A bigger mass leaves the hole alone and sends stars off-screen

The dark center in 09 is RING_RADIUS times 0.3, about 15px, and RING_RADIUS is the constant Math.sqrt(LENS_MASS), so editing LENS_MASS in the source enlarges the hole too. A tap, which multiplies the mass by 1.9 while running, grows the ring but leaves the hole at 15px. Either way the outer images move far from the original spot and rim stars leave the screen.

Narrow screens and how to check the edges yourself

The canvas keeps aspect-ratio 480 / 300 and only shrinks in width, and at 340px and below the caption, button and status text drop to 10px. You can find each edge above by changing SPREAD, PARALLAX, LINK_RADIUS and LENS_MASS one at a time. The archive opens with the password 2yqn4mvf and each folder holds the finished index.html to edit.

Three Variants and Accessibility

Name Value Feel
Change the speed 01 SPEED 12 → 24 Raising it lengthens the mean streak from 5.0px to 11.4px, and center stars reach the edge almost at once.
Change the link radius 07 LINK_RADIUS 72 → 110 Lines go from 158 to 161, nearly unchanged, while the mean length grows from 47.3px to 80.7px and far stars join too.
Change the lens mass 09 LENS_MASS 2600 → 5200 The ring radius grows from 51px to 72px and drawn images rise from 454 to 510, so starlight is pushed farther out.

All three figures in the table are counted in a node simulation, not in a browser.

With prefers-reduced-motion set to reduce, none of the nine files starts a requestAnimationFrame loop; each scene is advanced 88 ticks by advance(1400) at the start and drawn once. The button becomes Next scene, each press advances REDUCE_STEP_MS 900 ms (56 ticks) and draws once, and pointer, key and tap input go through refresh(), which runs 6 ticks (96 ms) at once so the input shows.

The canvas has tabindex 0 and takes focus, Enter and Space call tap(), and the arrow keys move a virtual pointer by KEY_STEP 14px. Only those six keys call preventDefault and every other key, Tab included, passes through, while the status text is rewritten inside an aria-live="polite" region only when the scene's wording changes.

Are CSS Keyframes Alone Enough for a CSS Starfield Animation?

For dots that twinkle in place or drift one way, yes. A star coming toward you, layers at different speeds and a sky turning on a sphere need a different value per star, which is the point where a star background moves to canvas calculation and CSS keeps only the frame.

The line to draw is whether a star must know its own distance or its neighbors. Decoration where stars know nothing about each other stays in CSS, while dividing by z per star as in 01 or finding close stars to join as in 07 belongs in code. This page's code also advances in fixed 16 ms ticks rather than by raw frame time, taken from the requestAnimationFrame timestamp, and a slow frame is caught up by at most 160 ms, so a stalled tab does not make the stars jump.

CSS still owns the frame here. The style.scss holds the canvas to its aspect-ratio, uses touch-action: none so a pointer drag does not leak into scrolling, and every star inside is computed on the canvas.

Frequently Asked Questions Before Building a JavaScript Starfield

Which value should I change first in a js starfield?

In 01 it is SPEED and STAR_COUNT. If the speed feels low raise SPEED, and if streaks run too long lower STREAK_LEN; the two are independent, so change one at a time to see which one acts. The star-count constant is named STAR_TOTAL or NODE_COUNT in other scenes but always sits at the top of the file, and lowering it lightens the screen.

Where do I change the star colors?

Pick them in the five STAR_RGBS values (white-blue, apricot, lilac, mint, pink) and the background BG. Scene 04 uses three of its own, WARM, WHITE and COOL, and 08 sets the tail color with HEAD and TAIL. When you switch to deep colors, check the brightness gap against BG, and where two star colors are mixed as in the lines of 07, change both together.

Do I need a library or images?

No. None of the nine files calls an outside address and every star is drawn on canvas 2D, so one file carried to another folder still runs.

Enter the archive password

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