Rain
A 2D canvas draws 120 diagonal raindrops via simple pixel addition, with a ripple ring on impact — pure vanilla, zero dependencies.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Atmosphere has 57 effects, including 10 free. Explore the category →
3 usage examples



How it works
The requestAnimationFrame loop runs unconditionally — no IntersectionObserver, no pause mechanism. Each frame, the canvas is fully covered with #0a0a0f (fillRect), erasing the previous frame with no trail. The 120 drops are instantiated at load: each stores {x, y, len, speed} with len ∈ [8,23] px and speed ∈ [4,10] px/frame — fixed per drop, sampled once.
Each drop is rendered as a 1-px stroke from (x, y) to (x + 1, y + len). The constant +1 px/frame horizontal drift sets the diagonal angle: arctan(1/speed), roughly 6–14° depending on the drop's speed. Speed is applied directly (y += speed, x += 1) without delta-time normalization — smoothness is frame-rate dependent. Color: rgba(150,180,220,0.3), blue-grey at 30% opacity.
When y > h, the drop resets (y = -len, random x) and a splash {x, y: h-2, life: 1} is pushed to a second array. Each frame: life -= 0.05 over ~20 frames (~333 ms at 60 fps). The splash renders as an arc of radius 5 × (1 - life): it grows from 0 to 5 px while opacity (0.3 × life) fades out. This is an ephemeral ripple ring — not a persistent ground pool.
The effect implements neither prefers-reduced-motion, nor a ResizeObserver, nor a public API. The canvas is sized once at load via parentElement.getBoundingClientRect(). The IIFE targets getElementById('psRain'): single-instance design, one canvas per page.
Accessibility
- prefers-reduced-motion not implemented: no
window.matchMedia('(prefers-reduced-motion:reduce)')check exists — the animation runs perpetually, even for users sensitive to motion. Add this check manually in your integration. - The
<canvas>does not carryaria-hidden="true"in the source code — screen readers may announce an empty canvas element. Add this attribute in your integration HTML. - The
.ps-canvas-wrapcontainer has neitherrole="img"noraria-label— the effect is semantically invisible to assistive technology. Add a descriptive label on the parent element. - No interactive elements (click, focus): the scene is purely decorative. No intrinsic keyboard navigation issue, but ensure any overlay text remains in the normal tab order.
- Overlay contrast: the canvas alone (background
#0a0a0f, drops at 30% opacity) does not itself constrain text contrast — add a semi-opaque scrim over any text overlay to maintain WCAG AA (ratio ≥ 4.5:1).
Browser compatibility
Requires Canvas 2D Context and requestAnimationFrame — supported in all modern browsers. Zero external dependencies.
Without <code>canvas</code> support, the parent <code><div></code> remains visible with its background — no fatal JS errors, overlay text content stays readable.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="ps-canvas-wrap" role="img" aria-label="Animated rain background">
<canvas class="ps-canvas" id="psRain" aria-hidden="true"></canvas>
<!-- Optional: scrim + overlay content -->
<div class="ps-scrim" aria-hidden="true"></div>
<div class="ps-content">
<h2 class="ps-title">Your message</h2>
<button class="ps-cta">CTA</button>
</div>
</div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| Drop count (const 120) | 120 | Init loop: for (let n = 0; n < 120; n++). 40 = drizzle, 200 = heavy storm. Above 250, performance drops on low-end mobile. |
| Speed per drop (speed: 6×r+4) | 4–10 px/frame | Replace 6 * Math.random() + 4: 3 * Math.random() + 2 = slow drizzle, 10 * Math.random() + 8 = violent storm. Not delta-time normalized — frame-rate dependent. |
| Trail length (len: 15×r+8) | 8–23 px | Replace 15 * Math.random() + 8: short trails (4 + 4 * Math.random()) = fine rain; long (20 + 20 * Math.random()) = torrential downpour. |
| Horizontal drift (e.x += 1) | 1 px/frame | The line e.x += 1 sets the diagonal angle. Increase to 2–3 for strong crosswind; set to 0 for pure vertical fall; negative value for right-to-left rain. |
| Drop color (rgba) | rgba(150,180,220,0.3) | Change color and opacity inline in the loop: rgba(220,230,255,0.5) = bright cold rain, rgba(100,130,100,0.3) = stormy green tint, opacity 0.15 = very subtle backdrop. |
| Splash max radius (5) | 0–5 px | Modify 5 * (1 - c.life): 8–12 px = visible impact on large screens; 2–3 px = subtle shimmer. Ripple duration controlled by life -= 0.05 (20 frames ≈ 333 ms). |
FAQ
IntersectionObserver. To add one: declare a flag let active = true before the IIFE, observe the .ps-canvas-wrap and toggle active on intersection change. In the loop, replace requestAnimationFrame(c) with if (active) requestAnimationFrame(c);.getElementById('psRain') — one canvas per page by default. For multiple instances, extract the logic into an initRain(id) function keeping drops and splashes in a closure, then call it for each canvas with a distinct id. The global setupCanvas(id) helper defined outside the IIFE is directly reusable.if (window.matchMedia('(prefers-reduced-motion:reduce)').matches) return;. You can optionally draw a single static frame first to preserve the visual mood without continuous animation.