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

9 CSS Rain Animation Layers for a Storm Window

This css rain animation set turns a storm window into nine canvas layers. One wind-vector value in this background scene sets the tilt and speed of all nine

See All Nine Rain Layers Move Right on This Page

Scroll through the grid below and each of the nine canvases is already running its own loop — nothing waits for a click.

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

9 CSS Rain Animation Layers That Stack Into One Storm

The set covers a full html css rain effect toolkit: depth-based streak layers, a shifting wind vector, merging window droplets, ground splashes, a slow intensity cycle, branching lightning, pointer-driven lean, umbrella occlusion, and a hero scene that stacks four of those techniques into one storm window, all explained with source-level detail in HTML5 Canvas Animation: 9 Warp & Droplet Effects. Open any item below to read the exact function that produces it.

01Rain Streaks Parallaxing at a Different Thickness and Speed Per Depth Layer

Each streak carries a virtual depth that sets its thickness and fall speed in inverse proportion to distance, so a near streak falls thick and fast while a far one stays thin and slow. The streakDepthZ() function derives that depth for every layer up to RAIN_LAYER_COUNT, and the resulting streaks array is sorted by depth so far layers paint first and near layers paint over them.

Rain Streaks Parallaxing at a Different Thickness and Speed Per Depth Layer
Open in a new tab

01 · Rain Streaks Parallaxing at a Different Thickness and Speed Per Depth Layer — Measured area 63.301% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

streakDepthZRAIN_NEAR_SPEED_PX_SRAIN_LAYER_COUNT
  function streakDepthZ(layerIndex) {
    return RAIN_LAYER_COUNT <= 1 ? 1 : layerIndex / (RAIN_LAYER_COUNT - 1);
  }

  var streaks = [];
  for (var li = 0; li < RAIN_LAYER_COUNT; li += 1) {
    var z = streakDepthZ(li);
    var count = Math.round(32 - z * 18);
    var speed = RAIN_FAR_SPEED_PX_S + (RAIN_NEAR_SPEED_PX_S - RAIN_FAR_SPEED_PX_S) * z;
    var thick = 0.5 + z * 2.1;
    var alpha = 0.28 + z * 0.62;
    for (var i = 0; i < count; i += 1) {
      streaks.push({
        x: rRand() * CALC_W,
        y: rRand() * CALC_H,
        z: z, speed: speed, thick: thick, alpha: alpha,
        len: 4 + z * 12
      });
    }
  }
  streaks.sort(function (a, b) { return a.z - b.z; });

Item 01 repainted 63.3% of the frame at an average intensity of 111.8 across 23/23 measured frames.

02Rain Streaks Bending as the Wind Direction Shifts Over Time

A slow-oscillating wave value keeps recalculating wind strength and angle, so every streak's tilt and fall speed leans together with that same wind value. windGustAngle() combines two sine waves running on separate cycles set by RAIN_WIND_CYCLE_MS, then scales the result by WIND_GUST_MAX_DEG to cap how far a gust can tilt the rain.

Rain Streaks Bending as the Wind Direction Shifts Over Time
Open in a new tab

02 · Rain Streaks Bending as the Wind Direction Shifts Over Time — Measured area 57.836% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

windGustAngleWIND_GUST_MAX_DEGRAIN_WIND_CYCLE_MS
  function windGustAngle(nowMs) {
    var a = Math.sin(nowMs / RAIN_WIND_CYCLE_MS * Math.PI * 2) * 0.62;
    var b = Math.sin(nowMs / (RAIN_WIND_CYCLE_MS * 1.73) * Math.PI * 2 + 1.3) * 0.38;
    return (a + b) * WIND_GUST_MAX_DEG;
  }

  var streaks = [];
  for (var i = 0; i < RAIN_STREAK_COUNT; i += 1) {
    streaks.push({
      x: rRand() * CALC_W,
      y: rRand() * CALC_H,
      speed: RAIN_FALL_SPEED_PX_S * (0.6 + rRand() * 0.7),
      thick: 0.8 + rRand() * 1.4
    });
  }

  var elapsed = 0;
  function stepSim(dtS, nowMs) {

Item 02 repainted 57.8% of the frame at an average intensity of 104.1 across 23/23 measured frames.

03Window Droplets Trickling Down and Merging Into Each Other

Each droplet's weight and drag are recalculated to set its slide speed, and when its path overlaps another droplet the two merge into one larger drop. dropletMassMerge() adds the pair's mass together and keeps the faster vy value, while DROPLET_GRAVITY_PX_S drives the target fall speed that stepSim() eases every droplet toward each frame.

Window Droplets Trickling Down and Merging Into Each Other
Open in a new tab

03 · Window Droplets Trickling Down and Merging Into Each Other — Measured area 8.967% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

dropletMassMergeDROPLET_GRAVITY_PX_SDROPLET_MERGE_DIST_PX
  function dropletMassMerge(a, b) {
    var mass = a.mass + b.mass;
    var r = Math.sqrt(mass) * 0.92;
    return {
      x: (a.x * a.mass + b.x * b.mass) / mass,
      y: Math.max(a.y, b.y),
      r: r, mass: mass, vy: Math.max(a.vy, b.vy), seed: a.seed
    };
  }

  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    spawnAcc += dtMs * DROPLET_SPAWN_PER_S / 1000;
    while (spawnAcc >= 1 && drops.length < 46) { spawnDroplet(); spawnAcc -= 1; }
    for (var i = 0; i < drops.length; i += 1) {
      var d = drops[i];
      var targetVy = (DROPLET_GRAVITY_PX_S * Math.sqrt(d.mass)) / DROPLET_DRAG_COEF;
      d.vy += (targetVy - d.vy) * Math.min(1, 5 * dtS);
      d.y += d.vy * dtS;
      d.x += Math.sin(nowMs * 0.0012 + d.seed) * 1.6 * dtS;
    }
    // merge — combine two overlapping droplets into one
    for (var a = 0; a < drops.length; a += 1) {
      for (var b = a + 1; b < drops.length; b += 1) {
        var p1 = drops[a], p2 = drops[b];

Item 03 repainted 9.0% of the frame at an average intensity of 80.9 across 23/23 measured frames.

04Splash Bursts Where Each Raindrop Lands

The moment a drop hits the ground it spawns several splash particles with randomly split directions and speeds, and gravity is re-added to each one every frame so they scatter along a curved path. splashBurst() creates SPLASH_PARTICLE_COUNT particles per impact, and SPLASH_GRAVITY_PX_S2 pulls each one back down once stepSim() detects a streak has reached the ground line.

Splash Bursts Where Each Raindrop Lands
Open in a new tab

04 · Splash Bursts Where Each Raindrop Lands — Measured area 48.23% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPLASH_PARTICLE_COUNTsplashBurstSPLASH_GRAVITY_PX_S2
  function splashBurst(x, nowMs) {
    for (var i = 0; i < SPLASH_PARTICLE_COUNT; i += 1) {
      var ang = Math.PI + sRand() * Math.PI; // spread across the upper half-circle
      var speed = 16 + sRand() * 26;
      splashes.push({
        x: x, y: GROUND_Y,
        vx: Math.cos(ang) * speed,
        vy: Math.sin(ang) * speed,
        born: nowMs
      });
    }
  }

  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < streaks.length; i += 1) {
      var s = streaks[i];
      s.y += s.speed * dtS;
      if (s.y >= GROUND_Y) {
        splashBurst(s.x, nowMs);

Item 04 repainted 48.2% of the frame at an average intensity of 78.5 across 23/23 measured frames.

05Scene Cycling in Intensity from Drizzle to Downpour

One slow periodic function keeps the storm's intensity rising and falling, and the streak count and wind strength are recalculated from that same value every frame so the whole scene weakens and strengthens together. stormIntensity() runs on a STORM_CYCLE_MS period, and stepSim() reads that value to set both the wind offset and how large a share of the pooled streaks RAIN_SPAWN_RATE_BASE lets fall at the current intensity.

Scene Cycling in Intensity from Drizzle to Downpour
Open in a new tab

05 · Scene Cycling in Intensity from Drizzle to Downpour — Measured area 60.446% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

stormIntensitySTORM_CYCLE_MSRAIN_SPAWN_RATE_BASE
  function stormIntensity(nowMs) {
    return Math.sin((nowMs / STORM_CYCLE_MS) * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5;
  }

  var streaks = [];
  for (var i = 0; i < RAIN_POOL_SIZE; i += 1) {
    streaks.push({
      x: rRand() * CALC_W,
      y: rRand() * CALC_H,
      thresh: rRand(),
      speed: 90 + rRand() * 90,
      thick: 0.7 + rRand() * 1.3
    });
  }

  function stepSim(dtS, nowMs) {
    var inten = stormIntensity(nowMs);
    var wind = (inten - 0.5) * 20;
    for (var i = 0; i < streaks.length; i += 1) {
      var s = streaks[i];

Item 05 repainted 60.4% of the frame at an average intensity of 122.6 across 23/23 measured frames.

06Branching Lightning Bolt With a Full-Screen Flash

The bolt's path is built by walking each branch through three or four short segments that bend the angle at every step, while screen brightness is computed on a decay curve that spikes up fast and eases back down to create the flash. branchLightning() recurses down to LIGHTNING_BRANCH_DEPTH, and once a bolt lands the flash brightness fades out over LIGHTNING_FLASH_DECAY_MS.

Branching Lightning Bolt With a Full-Screen Flash
Open in a new tab

06 · Branching Lightning Bolt With a Full-Screen Flash — Measured area 73.354% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIGHTNING_FLASH_DECAY_MSbranchLightningLIGHTNING_BRANCH_DEPTH
  function branchLightning(segs, x, y, ang, len, depth) {
    if (depth <= 0 || len < 3) return;
    var steps = 3 + Math.floor(rRand() * 2);
    var cx = x, cy = y, cang = ang;
    for (var i = 0; i < steps; i += 1) {
      cang += (rRand() - 0.5) * 0.9;
      var stepLen = len / steps;
      var nx = cx + Math.sin(cang) * stepLen;
      var ny = cy + Math.cos(cang) * stepLen;
      segs.push({ x0: cx, y0: cy, x1: nx, y1: ny, depth: depth });
      if (depth > 1 && rRand() < 0.3) {
        var side = rRand() < 0.5 ? -1 : 1;
        branchLightning(segs, nx, ny, cang + side * (0.6 + rRand() * 0.5), len * 0.5, depth - 1);
      }
      cx = nx; cy = ny;
    }
  }

  function makeBolt() {
    var segs = [];
    var startX = 24 + rRand() * 112;

Item 06 repainted 73.4% of the frame at an average intensity of 51.7 across 23/23 measured frames.

07Background Wind Leaning Toward the Pointer Position

The wind vector's direction is recalculated by how far the pointer sits from the center, so every streak leans and falls in that same direction while the pointer moves. windLeanTarget() multiplies that normalized offset by WIND_LEAN_MAX_DEG to produce the target lean angle, and POINTER_WIND_LERP eases the current lean toward it each frame — until a pointer or key input arrives, and for 2.6 seconds after the last one, virtualPointerSweep() drives the same target on a sine sweep instead.

Background Wind Leaning Toward the Pointer Position
Open in a new tab

07 · Background Wind Leaning Toward the Pointer Position — Measured area 16.376% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

windLeanTargetPOINTER_WIND_LERPvirtualPointerSweep
  function windLeanTarget(normX) {
    return normX * WIND_LEAN_MAX_DEG;
  }

  function virtualPointerSweep(nowMs) {
    return Math.sin((nowMs / SWEEP_PERIOD_MS) * Math.PI * 2);
  }

  var leanDeg = 0;
  function stepSim(dtS, nowMs) {
    var normX = manual ? pointerNormX : virtualPointerSweep(nowMs);
    var target = windLeanTarget(normX);
    leanDeg += (target - leanDeg) * Math.min(1, POINTER_WIND_LERP * (dtS * 60));

Item 07 repainted 16.4% of the frame at an average intensity of 62.6 across 23/23 measured frames.

08Rain Streaks Occluded Behind an Umbrella Shape

Streaks passing behind the umbrella silhouette never show through it because the canopy is painted over the rain later in the same draw pass, and drops that land on the canopy are bounced off at an angle computed from where they hit. umbrellaOcclusion() tests each streak's coordinate against the canopy's curved edge to detect the hit, and umbrellaBounce() spawns UMBRELLA_BOUNCE_COUNT particles outward once a drop registers one.

Rain Streaks Occluded Behind an Umbrella Shape
Open in a new tab

08 · Rain Streaks Occluded Behind an Umbrella Shape — Measured area 40.252% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

umbrellaOcclusionUMBRELLA_BOUNCE_COUNTumbrellaBounce
  function umbrellaOcclusion(x, y, umbX) {
    var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
    if (Math.abs(dx) > 1) return false;
    var edgeY = umbY - UMBRELLA_HEIGHT_PX * Math.sqrt(Math.max(0, 1 - dx * dx));
    return y >= edgeY && y <= umbY;
  }

  function umbrellaBounce(x, y, umbX) {
    var dx = (x - umbX) / UMBRELLA_RADIUS_PX;
    var nx = dx;
    for (var i = 0; i < UMBRELLA_BOUNCE_COUNT; i += 1) {
      var spread = (rRand() - 0.5) * 40;
      bounces.push({
        x: x,
        y: y,
        vx: nx * (40 + rRand() * 30) + spread,
        vy: -(30 + rRand() * 40),
        born: 0,
        life: 380 + rRand() * 200
      });
    }
  }

  function stepSim(dtS, nowMs) {

Item 08 repainted 40.3% of the frame at an average intensity of 52.7 across 23/23 measured frames.

09Hero Storm Scene Combining Depth Layers, Wind, Droplets, and Lightning

Layer parallax from 01, wind gusts from 02, window droplets from 03, and the lightning flash from 06 are computed together on one canvas to build the finished storm-window scene. This scene reimplements windGustAngle() and dropletMassMerge() locally with hero-scoped constants like HERO_WIND_GUST_MAX_DEG and HERO_GLASS_TOP, and it carries no pointer listener of its own — item 07 is the only layer in this set that reads pointer or keyboard input.

Hero Storm Scene Combining Depth Layers, Wind, Droplets, and Lightning
Open in a new tab

09 · Hero Storm Scene Combining Depth Layers, Wind, Droplets, and Lightning — Measured area 73.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HERO_WIND_GUST_MAX_DEGHERO_LIGHTNING_FLASH_DECAY_MSHERO_GLASS_TOP
  function windGustAngle(nowMs) {
    var a = Math.sin((nowMs / HERO_WIND_CYCLE_MS) * Math.PI * 2) * 0.7;
    var b = Math.sin((nowMs / (HERO_WIND_CYCLE_MS * 0.37)) * Math.PI * 2) * 0.3;
    return (a + b) * HERO_WIND_GUST_MAX_DEG;
  }

  var droplets = [];
  var dropletSpawnAcc = 0;
  function spawnDroplet() {
    droplets.push({
      x: HERO_GLASS_X0 + rRand() * (HERO_GLASS_X1 - HERO_GLASS_X0),
      y: HERO_GLASS_TOP + rRand() * 3,
      vy: 0,
      r: 0.9 + rRand() * 1.1
    });
  }
  var HERO_GLASS_X0 = CALC_W * 0.06, HERO_GLASS_X1 = CALC_W * 0.94;

  function dropletMassMerge(a, b) {
    var ar = a.r * a.r, br = b.r * b.r;
    a.r = Math.sqrt(ar + br) * 0.86;
    a.vy = (a.vy * ar + b.vy * br) / (ar + br);
  }

Item 09 repainted 73.9% of the frame at an average intensity of 83.5 across 23/23 measured frames.

Code for Each Rain Layer Technique

Reading the nine rows together, the composite scene (09) and the lightning layer (06) sit at the top of the repaint column, with 01's layered streaks and 05's storm cycle close behind, because they redraw the widest strokes across the canvas — a lightning bolt fills a tall wedge of pixels in one hit, and a full streak pool redraws at once every frame. Item 03's droplets sit at the bottom because a handful of small circles on glass simply covers less area than a field of streaks, even though its merge math runs every frame regardless. Average intensity does not track repaint area at all: item 05 has the highest average pixel change (122.6) despite a mid-range repaint share, because its streaks move fast and land hard between measured frames, while item 06's average (51.7) stays low even with a large repaint share since its full-screen flash only fires once per strike period and fades within LIGHTNING_FLASH_DECAY_MS, leaving thin streaks over a static dark background for most frames.

Item Layer Repaint area Avg. intensity Moved frames
01 Depth-parallax streaks 63.3% 111.8 23/23
02 Wind-gust drift 57.8% 104.1 23/23
03 Window droplet trickle 9.0% 80.9 23/23
04 Ground splash physics 48.2% 78.5 23/23
05 Storm intensity cycle 60.4% 122.6 23/23
06 Lightning branch flash 73.4% 51.7 23/23
07 Pointer wind lean 16.4% 62.6 23/23
08 Umbrella occlusion 40.3% 52.7 23/23
09 Hero composite scene 73.9% 83.5 23/23

Where the Values Change the Storm — Wind, Spacing, Lightning, Splash

Three constants decide how far each technique reads before it starts to feel like a different storm. WIND_GUST_MAX_DEG in item 02 caps how sharply a gust can lean the rain; SPLASH_PARTICLE_COUNT in item 04 sets how crowded the ground gets when a streak lands; LIGHTNING_FLASH_DECAY_MS in item 06 controls how long the screen stays bright after a bolt. None of the three rewrites a draw routine or the loop cadence — each one only scales a number the per-frame code already consumes, which is the same pattern covered in JavaScript Physics Simulation: 9 Demos, No Library for spring and inertia constants. Try the variant table further down to see each one move independently of the other eight layers.

Where It Breaks — Reduced Motion, Narrow Screens, Too Many Layers

All nine canvases check prefers-reduced-motion through matchMedia before their loop starts, so a visitor with reduced motion enabled sees a still frame instead of a moving storm — this is not optional per layer, it is baked into every item's setup code the same way. On a narrow viewport the canvases scale down with fit(), but stacking several full layers (01, 02, 05, 09) on one page at once adds up: each layer runs its own requestAnimationFrame loop, so four heavy layers on a single scroll position cost roughly four times the per-frame math of one. Item 09 alone already recomputes two techniques' worth of streaks and droplets in a single tick, so pairing it with 01 or 05 on the same screen is the combination most likely to visibly stutter on a slower device — the fix is picking one hero-weight layer per screen instead of layering several. The nine index.html files behind every item above come zipped together with password qms3kcmq so you can open the exact constants and compare them side by side.

Three Variants and Accessibility

Variant Value Effect
Adjust the maximum wind-lean angle 02 WIND_GUST_MAX_DEG 8 → 20 Lowering the value keeps the streaks falling almost straight down even in a strong gust, while raising it tilts them sharply from just a light breeze.
Adjust the splash particle count 04 SPLASH_PARTICLE_COUNT 6 → 14 Lowering the value makes the splash barely visible when a drop lands, while raising it crowds the ground with scattering particles until it reads as a downpour.
Adjust the lightning flash decay time 06 LIGHTNING_FLASH_DECAY_MS 220 → 500 Lowering the value makes the flash pop for only an instant before going dark again, while raising it leaves the glow lingering on screen like an afterimage.

Accessibility here is not a separate layer bolted on afterward — every one of the nine Canvas API scenes reads the prefers-reduced-motion preference up front and skips its per-frame stepSim()/draw() calls inside the requestAnimationFrame loop while that preference is set. Item 07 is the one item in this css rain background set built for direct interaction: moving the pointer or pressing the left and right arrow keys leans the wind toward that side in real time, and its root carries tabindex="0" with a descriptive label so keyboard-only visitors can trigger the same lean without a mouse. The other eight items are presentational scenes with no controls to operate, so their accessibility responsibility begins and ends with respecting the reduced-motion preference correctly.

What Does a CSS Rain Background Actually Calculate for a Window Scene?

Every item above answers the same underlying question differently: what should this pixel do on this frame, given the wind, gravity, or intensity value read a moment earlier? None of the nine loops plays back a recorded path — each one derives streak position, droplet size, splash direction, or bolt shape from a small set of numbers recalculated every tick, which is the same computed-not-recorded approach behind Image Ripple Effect CSS: 9 Canvas Wave Patterns. That is also why the variant table above changes the whole feel of a layer by editing one constant instead of redrawing any art: the geometry was never fixed to begin with.

Frequently Asked Questions Before You Use This Rainy CSS Scene

Is any of this rain effect javascript reading real weather data?

No. Every value — wind angle, storm intensity, lightning timing — comes from a seeded pseudo-random generator and a handful of sine waves running on fixed periods like RAIN_WIND_CYCLE_MS and STORM_CYCLE_MS, not from any outside source.

Why does item 09 look different from combining 01, 02, 03, and 06 myself?

Item 09 does not call into the other four items' functions at runtime — it reimplements windGustAngle() and dropletMassMerge() locally with its own hero-scoped constants, so its numbers are tuned separately even though the underlying techniques match.

Can I drop just one of these nine layers into an existing page?

Yes — each item's canvas, seedRand() setup, and stepSim()/draw() pair are self-contained in its own index.html, so copying one item does not require pulling in code from any of the other eight.

Enter the archive password

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