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

Moving Particles Background CSS: 9 Scroll Scenes

Nine moving particles background CSS scenes: one speeds spawn with scroll velocity, one shifts each particle by depth, and one strengthens a vortex's pull

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

Each of the nine skips every network call, skips localStorage, and skips Math.random too — the only input any of them reads is a single scroll value (position, velocity, or direction), and exactly one particle property in that scene answers it: a rise speed, a spawn rate, a lane direction, a depth offset, a spring target, a burst count, a hue, a pull radius, or a noise offset. That narrow scope is exactly why picking the right animated particle background css scene rarely needs more than knowing which particle property moves — the code underneath just confirms the mechanism once a name already points at the right one.

Anatomy of nine patterns — which value reacts to scroll

Item 01's step() raises particles at a RISE_SPEED value bent sideways by a WIND_GAIN-scaled scroll velocity and a FIELD_FREQ sine field; item 02's step() turns scroll velocity into a SPAWN_GAIN-driven spawn rate while each particle decays under FRICTION after launch; item 03's step() reads a laneSign/dirSign pair so a lane's own drift at LANE_SPEED flips direction the instant scroll direction reverses; item 04's project() runs an FOV/depth divide against a DOLLY_RANGE-scaled cameraZ(), so the same scroll distance moves a shallow particle further than a deep one; item 05's step() eases a STIFFNESS spring back toward rest under SETTLE_DAMP once scroll goes idle, with an idleMs timer counting how long that quiet has lasted; item 06's sampleScroll() watches for a FLICK_THRESHOLD rising edge to call burst(); item 07's bandColor() lerps across a BAND_A/BAND_B/BAND_C three-stop ramp as scroll progress crosses each boundary; item 08's step() shrinks a vortex's rest radius by REST_SHRINK as scroll nears a fixed point, then spins each particle at an angular rate of VORTEX_K divided by that same radius (floored at 8px so it never spins away to infinity); and item 09's step() samples a hand-built valueNoise() — a 256-entry permutation table hashed and smoothed rather than a Math.random call — offset by scroll through SCROLL_WARP and NOISE_SCALE. All nine listen for scroll and resize on their own scroll container rather than the window, and all nine treat a wheel, touchstart, or pointerdown event as a real visitor arriving, at which point human() turns the .is-demo autoplay loop off for good; until then, that same autoplay drives a triangle-wave scrollTop through an identical driveTick() shape in every scene. Every driver — the autoplay loop and each scene's own per-frame step() alike — runs through requestAnimationFrame rather than a timer, and Math.random appears in none of the nine; each one seeds its own small linear-congruential generator instead, so a given seed always lays out the same starting particles. Under prefers-reduced-motion: reduce, all nine skip that per-frame loop entirely and call paintStatic() once, so a visitor asking for less motion still sees the scene's resting layout rather than a blank canvas.

Code per pattern

01Ambient drift field — a baseline flow where particles rise at a resting-speed value even without any scroll value

Even at a scroll value of zero, particles keep rising at a resting-speed value, and once a scroll value appears the other values layer on top of that flow. A WIND_GAIN-scaled scroll velocity pushes the whole field sideways while a FIELD_FREQ sine field adds per-particle sway, so the motion reads as one ambient current rather than nine independent drifters — a fit for a hero section or a quiet loading backdrop.

Ambient drift field — a baseline flow where particles rise at a resting-speed value even without any scroll value
Open in a new tab

01 · Ambient drift field — a baseline flow where particles rise at a resting-speed value even without any scroll value — Measured area 15.706% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RISE_SPEEDWIND_GAINFIELD_FREQ
  function step(dtSec) {
    var wind = scrollVel * WIND_GAIN;
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      p.y -= RISE_SPEED * p.speedVar * dtSec;
      var sway = Math.sin(p.y * FIELD_FREQ + p.phase) * SWAY_AMP;
      p.x += (wind + sway) * dtSec;
      if (p.y < -8) spawnAt(p, false);
      if (p.x < -8) p.x = W + 8; else if (p.x > W + 8) p.x = -8;
    }
  }

What the numbers say

Item 01 fills 15.71% of the frame cumulatively at an 88.9 average intensity while moving in 23 of 23 sampled frames — the highest cumulative area among the nine, because a resting rise never stops even between scroll events, so the field is still repainting on almost every sampled frame.

02Velocity-based spawn — the particle-spawn frequency value rises as the scroll-velocity value climbs

As the scroll-velocity value climbs, the number of particles spawned per frame value rises, and it settles back to a resting rate once scrolling stops. SPAWN_GAIN sets how sharply that spawn rate reacts to velocity, LAUNCH_GAIN sets how far each new particle launches outward, and FRICTION bleeds off that launch speed afterward — a fit for a hero banner that should feel busier while a visitor is actively scrolling past it.

Velocity-based spawn — the particle-spawn frequency value rises as the scroll-velocity value climbs
Open in a new tab

02 · Velocity-based spawn — the particle-spawn frequency value rises as the scroll-velocity value climbs — Measured area 8.292% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPAWN_GAINLAUNCH_GAINFRICTION
  function step(now, dtSec) {
    var rate = Math.min(140, Math.abs(scrollVel) * SPAWN_GAIN);
    spawnAcc += rate * dtSec;
    while (spawnAcc >= 1) { spawnOne(now); spawnAcc -= 1; }

    var decay = Math.exp(-FRICTION * dtSec);
    for (var i = particles.length - 1; i >= 0; i--) {
      var p = particles[i];
      p.vx *= decay; p.vy *= decay;
      p.x += p.vx * dtSec;
      p.y += p.vy * dtSec;
      if (now - p.born > LIFE_MS) particles.splice(i, 1);
    }
  }

What the numbers say

Item 02 fills 8.29% of the frame cumulatively at an 85.1 average intensity while moving in 23 of 23 sampled frames, because every spawn cycle also removes particles once their LIFE_MS runs out, so the painted area rises and falls with scroll velocity instead of only growing.

03Direction reverse — the particle flow's direction value flips the instant the scroll-direction value switches from down to up

The moment the scroll-direction value switches to up, the particles' flow-direction value flips, so the same spot reads differently scrolling down versus up. A laneSign/dirSign pair decides which way each lane drifts at a fixed LANE_SPEED, so reversing scroll direction reverses every lane at once rather than any single particle acting alone.

Direction reverse — the particle flow's direction value flips the instant the scroll-direction value switches from down to up
Open in a new tab

03 · Direction reverse — the particle flow's direction value flips the instant the scroll-direction value switches from down to up — Measured area 13.023% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

laneSigndirSignLANE_SPEED
  function step(dtSec) {
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      p.y -= RISE_SPEED * dtSec;
      p.x += laneSign(p.lane) * dirSign * LANE_SPEED * dtSec;
      var laneMin = p.lane / LANES * W, laneMax = (p.lane + 1) / LANES * W;
      if (p.x < laneMin - 6) p.x = laneMin - 6; else if (p.x > laneMax + 6) p.x = laneMax + 6;
      if (p.y < -8) spawnAt(p, false);
    }
  }

What the numbers say

Item 03 fills 13.02% of the frame cumulatively at a 101.9 average intensity while moving in 23 of 23 sampled frames, because a direction reversal repaints every particle in a lane at once rather than only the ones nearest an edge.

04Depth parallax layer — each particle carries its own depth value, so the same scroll distance applies at a different distance per depth value

Each particle is assigned a different depth value, so for the same scroll distance a shallow-depth particle moves further and a deep one moves less. A FOV/depth divide projects that difference onto the canvas while DOLLY_RANGE sets how far cameraZ() itself travels per scroll unit, so nudging the page moves the whole depth stack like a real camera dolly.

Depth parallax layer — each particle carries its own depth value, so the same scroll distance applies at a different distance per depth value
Open in a new tab

04 · Depth parallax layer — each particle carries its own depth value, so the same scroll distance applies at a different distance per depth value — Measured area 13.938% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOVDOLLY_RANGEcameraZ
  function cameraZ() { return scrollPos * DOLLY_RANGE; }

  function project(p, camZ) {
    var depth = p.z - camZ;
    var scale = FOV / (FOV + Math.max(depth, NEAR));
    return { sx: W / 2 + p.wx * scale, sy: H / 2 + p.wy * scale, scale: scale, depth: depth };
  }

  function step(dtSec) {
    var camZ = cameraZ();
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      if (p.z - camZ < NEAR) spawnAt(p, false); // a particle that passes the camera is repositioned to the farthest spot
    }
  }

What the numbers say

Item 04 fills 13.94% of the frame cumulatively at an 85.8 average intensity while moving in 23 of 23 sampled frames, because a shallow-depth particle's own projected scale change repaints a wider area than a deep particle moving the same world distance.

05Idle settle — once scrolling stops, particles fall under a gravity value and settle slowly downward

Once the scroll value stops changing, the particles' rising-flow value turns off and a gravity value takes over, so they settle slowly while the page is still. A STIFFNESS spring pulls each dot back toward rest under SETTLE_DAMP, while an idleMs timer counts how long scroll has stayed quiet — a fit for a footer or an end-of-page panel that should feel calm once a visitor stops scrolling.

Idle settle — once scrolling stops, particles fall under a gravity value and settle slowly downward
Open in a new tab

05 · Idle settle — once scrolling stops, particles fall under a gravity value and settle slowly downward — Measured area 8.342% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STIFFNESSSETTLE_DAMPtriangleHold
  function step(dtSec) {
    var target = scrollVel * DRIVE_GAIN;
    for (var i = 0; i < dots.length; i++) {
      var d = dots[i];
      var acc = (target - d.dispX) * d.stiff - d.velX * SETTLE_DAMP;
      d.velX += acc * dtSec;
      d.dispX += d.velX * dtSec;
    }
  }

What the numbers say

Item 05 fills 8.34% of the frame cumulatively at a 106.9 average intensity while moving in 23 of 23 sampled frames, because the spring keeps easing toward rest for a short stretch after scroll already stops, so motion continues past the last scroll event.

06Flick burst — a large jump in the scroll value within a short time scatters a burst of particles from that point

When the scroll value jumps sharply within a short window, the particle-count value spikes at that spot and scatters outward. A FLICK_THRESHOLD rising edge on the scroll-velocity value calls burst(), which scatters BURST_COUNT (40) particles from that point at once — a fit for a section boundary that should reward a fast flick past it.

Flick burst — a large jump in the scroll value within a short time scatters a burst of particles from that point
Open in a new tab

06 · Flick burst — a large jump in the scroll value within a short time scatters a burst of particles from that point — Measured area 3.719% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLICK_THRESHOLDBURST_COUNTflickWave
  function sampleScroll(now) {
    var top = viewport.scrollTop;
    var max = Math.max(1, viewport.scrollHeight - viewport.clientHeight);
    scrollPos = top / max;
    var dt = lastT ? Math.max(1, now - lastT) : 16;
    var v = (top - lastTop) / dt * 1000;
    scrollVel += (v - scrollVel) * 0.4;
    if (Math.abs(scrollVel) > 4) { scrollDir = scrollVel > 0 ? 1 : -1; idleMs = 0; } else { idleMs += dt; }
    lastTop = top; lastT = now;

    var absVel = Math.abs(scrollVel);
    if (prevAbsVel < FLICK_THRESHOLD && absVel >= FLICK_THRESHOLD) burst(now);
    prevAbsVel = absVel;
  }

What the numbers say

Item 06 fills only 3.72% of the frame cumulatively at a 69.5 average intensity while moving in just 12 of 23 sampled frames — the lowest cumulative area and the fewest moving frames among the nine, because a burst only paints while a flick's rising edge is actually crossing FLICK_THRESHOLD, and the field sits still the rest of the time.

07Section hue shift — the particles' color value steps to the next section's hue each time the scroll-progress value crosses a threshold

Each time the scroll-progress value crosses a set threshold, every particle's color value eases toward the hue assigned to that section. bandColor() lerps across a three-stop ramp while BOB_FREQ and BOB_AMP add a small per-particle bob so the field never looks perfectly still between color changes — a fit for a long page split into named sections.

Section hue shift — the particles' color value steps to the next section's hue each time the scroll-progress value crosses a threshold
Open in a new tab

07 · Section hue shift — the particles' color value steps to the next section's hue each time the scroll-progress value crosses a threshold — Measured area 7.906% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

bandColorBOB_FREQBOB_AMP
  function lerpRGB(c1, c2, t) {
    return { r: c1.r + (c2.r - c1.r) * t, g: c1.g + (c2.g - c1.g) * t, b: c1.b + (c2.b - c1.b) * t };
  }
  function bandColor(t) {
    t = ((t % 1) + 1) % 1;
    var c = t < 0.5 ? lerpRGB(BAND_A, BAND_B, t * 2) : lerpRGB(BAND_B, BAND_C, (t - 0.5) * 2);
    return 'rgb(' + (c.r | 0) + ',' + (c.g | 0) + ',' + (c.b | 0) + ')';
  }

What the numbers say

Item 07 fills 7.91% of the frame cumulatively at a 124.4 average intensity while moving in 23 of 23 sampled frames, because a color lerp repaints every particle's fill style on every frame even while its position barely changes.

08Vortex pull — a vortex's pull-strength value at a fixed point grows as the scroll-position value nears it

As the scroll-position value nears a fixed point, that point's vortex pull-strength value grows, drawing particles into a widening spiral. REST_SHRINK pulls each particle's resting radius inward as scroll nears the point, PULL_RATE sets how fast a particle's own radius eases toward that shrinking target, and VORTEX_K divided by that same radius (floored at 8px) sets how fast it spins once pulled in — a fit for a call-to-action section that should visually gather attention as a visitor approaches it.

Vortex pull — a vortex's pull-strength value at a fixed point grows as the scroll-position value nears it
Open in a new tab

08 · Vortex pull — a vortex's pull-strength value at a fixed point grows as the scroll-position value nears it — Measured area 7.172% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VORTEX_KPULL_RATEREST_SHRINK
  function step(dtSec) {
    var target = restR * (1 - scrollPos * REST_SHRINK);
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      p.r += (target - p.r) * PULL_RATE * dtSec;
      var angVel = VORTEX_K / Math.max(p.r, 8);
      p.theta += angVel * dtSec * 0.01 * spinDir;
    }
  }

What the numbers say

Item 08 fills 7.17% of the frame cumulatively at a 104.2 average intensity while moving in 23 of 23 sampled frames, because the vortex's own radius change affects every particle at once, while the angular spin alone would repaint only a narrower ring.

09Noise flow distortion — the scroll value pushes a 2D noise field's offset value so particle paths bend into a smooth flowing path

As the scroll value grows, the noise field's offset value shifts, so a particle path at the same spot bends smoothly toward a different direction over time. A hand-built valueNoise() reads a NOISE_SCALE-sized field offset by scroll through SCROLL_WARP, so the bend is smooth and continuous rather than a sharp per-particle jump — a fit for a background particle effect css scene that should feel alive without ever repeating the same path twice in view.

Noise flow distortion — the scroll value pushes a 2D noise field's offset value so particle paths bend into a smooth flowing path
Open in a new tab

09 · Noise flow distortion — the scroll value pushes a 2D noise field's offset value so particle paths bend into a smooth flowing path — Measured area 13.367% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

valueNoiseSCROLL_WARPNOISE_SCALE
  var PERM_SIZE = 256;
  var perm = (function () {
    var p = [];
    for (var i = 0; i < PERM_SIZE; i++) p.push(i);
    for (var j = PERM_SIZE - 1; j > 0; j--) {
      var k = Math.floor(rng() * (j + 1));
      var t = p[j]; p[j] = p[k]; p[k] = t;
    }
    return p.concat(p);
  })();
  function hash2(ix, iy) {
    var a = perm[(ix & 255)];
    return perm[(a + iy) & 255] / 255;
  }
  function smooth(t) { return t * t * (3 - 2 * t); }
  function valueNoise(x, y) {
    var x0 = Math.floor(x), y0 = Math.floor(y);
    var fx = smooth(x - x0), fy = smooth(y - y0);
    var v00 = hash2(x0, y0), v10 = hash2(x0 + 1, y0);
    var v01 = hash2(x0, y0 + 1), v11 = hash2(x0 + 1, y0 + 1);
    var a = v00 + (v10 - v00) * fx;
    var b = v01 + (v11 - v01) * fx;
    return a + (b - a) * fy;
  }

What the numbers say

Item 09 fills 13.37% of the frame cumulatively at the highest average intensity among the nine, 125.8, while moving in 23 of 23 sampled frames, because a noise field changes every particle's direction by a slightly different amount each frame, instead of moving a shared block of particles the same way at once.

Where values change position and color — velocity, depth, and position basis

A particle background html css layer usually means one uniform drift, yet three specific items here answer three completely different scroll bases. Item 02's spawn rate answers scroll velocity directly: SPAWN_GAIN reads how fast the page is moving right now, not how far it has moved, so a slow steady scroll spawns steadily while a fast flick spawns in a burst that settles the moment the flick ends. Item 04's depth layer answers scroll distance instead, but unevenly: the FOV/depth divide changes how far the same scroll distance moves a particle's projected screen position, so a shallow particle can cross the canvas while a deep one barely shifts for the same amount of scroll. Item 08's vortex pull, by contrast, answers scroll position on its own: REST_SHRINK cares only about how close the current scroll position sits to a fixed point, so scrolling quickly past that point and scrolling slowly up to it and stopping produce the same pull strength at the same position. These three constants stay tied to their own basis no matter which scene they sit in — item 02's spawn rate still answers velocity, item 04's projection still answers depth, item 08's pull still answers position — so only the surrounding numbers change.

That velocity-versus-depth-versus-position split matters most once a scene like this leaves this page for a real section. Scroll Trigger Animation: 9 Engines, No GSAP fires a single animation once a scroll threshold is crossed, rather than reading scroll velocity or position continuously the way every scene above does frame by frame. Scroll Inertia CSS — 9 Value Patterns eases one UI value back toward rest once scrolling stops, the same idle-settle idea item 05 uses here, but applied to a single value rather than to a whole field of particles at once. 9 Scrollytelling CSS Section Patterns, Copy-Paste swaps a whole section's own layout as scroll moves between sections, rather than changing one particle property continuously the way these nine scenes do. The zip unlocks with x3fyh6j2 once, and that single unlock covers every one of these nine scenes together, so there is no separate password to look up per item.

Where it breaks — fast scrolling, tab switching, reduced motion

Real scrolling is messier than a demo loop, and a particle background css scene like this shows it in three distinct spots.

Fast scrolling that never dips below the flick threshold

Item 06's sampleScroll() only calls burst() on a rising edge — the moment Math.abs(scrollVel) crosses FLICK_THRESHOLD from below. A continuous fast scroll that starts already above that threshold and never drops back under it fires exactly one burst at the very first crossing, not a stream of them, so scrolling through the whole section quickly can look strangely calm right after that first spike.

Returning to a hidden tab mid-scene

Every scene's autoplay drives viewport.scrollTop from driveTick()'s own triangle wave, and that wave's phase is computed from now - driveStart using the real timestamp requestAnimationFrame hands it. Because requestAnimationFrame itself pauses while a tab sits hidden, the wave's phase keeps advancing in real time underneath it; the virtual scroll position a visitor sees the moment they switch back can land anywhere in the two-second cycle, not wherever the scene had visually paused.

Reduced motion still reads whatever scroll position the page loaded at

paintStatic() never assumes a resting scroll value of zero — it calls sampleScroll() once first, so the static frame it paints reflects whatever scroll position the page happened to load at. A visitor with prefers-reduced-motion: reduce who opens item 08 partway down a long page sees the vortex already pulled toward whatever radius that scroll position implies, not the loosest resting spiral.

Three variants and accessibility

Lowering the flow speed from 0.6 to 0.25 pushes the noise field's offset value more slowly, so particle paths bend gently instead of sharply. Lowering the damping from 22 to 10 leaves an overshoot — particles slightly pass the settle value and spring back — so the fall feels bouncier. Raising the count from 40 to 90 makes the scattered-particle value far denser on a flick, so the screen reads noticeably busier for an instant.

Variant What changes What it doesn't touch
Slower noise-flow bend FLOW_SPEED 0.6 → 0.25 The valueNoise() permutation table, NOISE_SCALE, the scroll-to-offset mapping itself
Bouncier idle settle SETTLE_DAMP 22 → 10 STIFFNESS, the idleMs timer, which particles belong to which spring
Denser flick burst BURST_COUNT 40 → 90 FLICK_THRESHOLD, the rising-edge detection, burst()'s own spawn point

Every one of the nine marks its own <canvas> aria-hidden="true", since the moving scene is decorative rather than informational, and pairs it with a visible hint span that describes what the current value is doing in plain language rather than leaving that description only inside the canvas pixels. Each scroll container itself carries role="region" with its own aria-label naming what is scrolling inside it, so a screen reader announces the region before a visitor ever needs to interact with the canvas at all.

FAQ

Is this scroll particle background css set nine separate scripts, or one shared component with a config

Nine independent files — each scene keeps its own constants and its own step function, and the fence shown above a demo is a direct excerpt from that same file rather than output from a shared generator that swaps numbers into one template.

Does item 09's flowing noise come from a real Perlin-noise library, or is it built from scratch

Built from scratch — a 256-entry permutation table gets shuffled once by the same seeded generator every other scene uses, then hash2() and a smooth() interpolation turn that table into a continuous field, with no external noise library or CDN involved.

Why is each canvas marked aria-hidden if it reacts to scroll

Because the canvas itself carries no information a visitor needs beyond what the visible hint text next to it already states; hiding the canvas from assistive tech avoids announcing constantly changing pixel content while the hint span carries the same idea in plain language instead.

Sources: MDN — Canvas API · MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — Document: scroll event

Enter the archive password

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