Rain
Layered canvas rainfall — parallax sheets of streaks sheared by a wind prop, landing as ripples on an implied ground line.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rain.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Rain" component — layered rainfall
painted on one canvas, used as a hero / auth / error-page backdrop. Its only
dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Rain(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- layers?: number (default 3) — parallax sheets, clamped to 1..4 and rounded.
- density?: number (default 90) — drops PER SHEET. The effective per-sheet
count is min(density, floor(area / 900), floor(900 / layers)): an area cap
so a small card is not carpeted, and a 900-drop total so a typo'd density
cannot stutter the tab.
- speed?: number (default 1) — multiplier on the whole simulation. 0 freezes
it and the rAF loop never starts.
- wind?: number (default 0.15) — horizontal shear as a fraction of fall
speed, clamped to -1.5..1.5. Negative blows left, 0 is dead calm.
- splash?: boolean (default true) — impact ripples on the ground line.
- ground?: number (default 0.97) — where drops land, as a fraction of the
container height, clamped to 0.1..1.
- tone?: "foreground" | "primary" | "muted" (default "foreground") — which
semantic token the rain is painted with.
- seed?: number (default 11) — integer seed for the shower.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat non-finite values as the
default. Each clamp exists for a failure: NaN layers empties the sky, a
negative speed runs the rain upward, a ground of 0 recycles every drop on
the frame it spawns, and wind past ~1.5 is more horizontal than vertical
and stops reading as rain.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that
is aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper
for children, so content always sits above the rain and the canvas can never
intercept a click or a scroll. The component paints NO background of its own:
the surface belongs to the consumer, so there is no fade-to-black trail trick
here (that would force a colour and break theming); every frame is a plain
clearRect plus fresh geometry.
- Depth ramp: sheet i of n has depth d = (i + 1) / n and every property is a
lerp along d — fall speed 320 to 820 CSS px/s, line width 0.6 to 1.5, alpha
0.16 to 0.50, wind shear weight 0.70 to 1.00. One ramp is what makes near
sheets read as near, and because wind is weighted by it too, the sheets sit
at slightly different angles instead of moving as one rigid pane.
- Shutter streak model: a streak is how far the drop travels while the shutter
is open, so its vertical extent is fallSpeed * 0.038s (times a per-drop 0.8
to 1.2 variance). Length can therefore never disagree with speed — raise the
fall speed and the streaks lengthen for free. The drawn segment runs from
(x - shear*len, y - len) to (x, y), so a sheared drop is automatically longer
than a vertical one, which is what wind does in reality.
- Deterministic pool: every drop's position, speed, shear jitter and length
comes from an integer hash of (dropId, salt), where dropId mixes the seed
with the sheet and the drop's index. Address drops by index rather than
pulling from a PRNG stream: growing a sheet after a resize then never
reshuffles the drops already falling. Math.random() is never called — not
during render (purity/SSR) and not in the loop (screenshots must be
reproducible). Same seed, same shower.
- Zero per-frame allocation: drops are created once by the retarget step and
mutated forever; landing a drop rewrites the same object. Ripples live in a
fixed pool of 32 pre-built objects claimed through a ring cursor. Nothing in
the loop calls a constructor, pushes to an array or builds an object literal
— at full-screen size that is the difference between a smooth sheet and a
sawtooth of GC pauses.
- Interior seeding vs top seeding: a fresh field seeds y uniformly over
[-len, groundY] and x uniformly over [0, width], because in steady state the
horizontal distribution is uniform at every height. The field is therefore
already settled on frame one instead of arriving as a wave from the top.
- Respawn domain (the subtle one): a drop drifts shear * (groundY - y0) px
sideways over one fall. Respawning x over [0, width] would leave the upwind
edge starved from about a screen-height of travel onward — a triangular bald
patch that appears only after a few seconds of wind. Respawn over the shifted
domain [min(0, -travel), width + |travel|] instead: it feeds exactly the flux
the visible box consumes, at any wind, in either direction. Respawn y is
-len minus up to 48px of jitter so no arrival rhythm builds up.
- Landing: when a drop's head passes groundY it (1) may claim a ripple and
(2) respawns immediately. The ripple is pinned to groundY, not to the
overshot y, so a long frame cannot drop a ripple below the floor. Ripple
chance is 0.09 * depth and only sheets with depth >= 0.5 splash at all: far
drops are too small to land visibly. At the defaults that is roughly ten live
ripples. The pool is also the rate limiter — when impacts outrun it the ring
cursor overwrites the oldest ripple, which is a visibly clipped ring and the
honest failure mode, not an unbounded array.
- Ripple rendering: an ellipse at (x, groundY) with radii (r, r * 0.32). The
vertical squash is the entire ground-plane illusion; nothing is ever painted
for the floor itself. r = 11 * depth * (0.25 + 0.75 * sqrt(age/life)) so it
expands fast then coasts, and alpha is (1 - t)^2 so it is gone before it
grows conspicuous. Life is 0.42 SIMULATED seconds, so `speed` scales ripples
and fall together.
- Batched stroke: alpha and line width are constants OF THE SHEET, which is
precisely why a whole sheet is one beginPath, hundreds of moveTo/lineTo
pairs, and one stroke() call. Per-drop variety lives in length, angle and
position, which cost nothing. Cull first: skip a drop whose head is above the
top edge, or whose head and tail are both off the same side.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). The two scale
sources disagree in BOTH directions, so take whichever asks for more pixels,
capped at 2: emulated and remoted surfaces report a 1:1 device box while the
page renders at 2x, and real 2x windows sometimes report devicePixelRatio 1
while the box is right. When they agree, use the exact device box — that
absorbs sub-pixel rounding at 1.25x/1.5x. On 0.6px-wide streaks either
mistake is the difference between rain and smudge. Re-apply
ctx.setTransform after every resize (writing
canvas.width resets the context), recompute groundY, and rescale existing
drops in place rather than reseeding — opening a sidebar must not restart the
shower.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and
speed is above 0. dt is clamped to 1/30s so a backgrounded tab cannot
teleport the sheet on resume, and the time base resets when the loop
restarts. A background that burns a core in a hidden tab is a defect.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the ink and repaints when the loop is paused, so a still frame never keeps
the previous theme's colour.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and kept in the effect deps. Under
reduce, and equally at speed 0, the loop never starts and ONE frame is
composed: the drops already cover the whole box, plus six ripples scattered
along the ground line at staggered ages so the impact line still reads. A
blank box is not an acceptable reduced-motion state.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame,
the ResizeObserver, the IntersectionObserver, the MutationObserver and the
visibilitychange listener.
- Cost, honestly: it is stroke-bound, not fill-bound, and linear in drop
count. At the defaults on a wide hero that is ~270 line segments in 3 stroke
calls plus ~10 ellipse strokes per frame; the caps (900 drops total, one drop
per 900 CSS px2, DPR 2) are the safety valves. Density is the knob to turn
before anything else.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--primary) / var(--muted-foreground), set as an inline `color` on the
canvas and read back with getComputedStyle(canvas).color, then assigned to
strokeStyle verbatim. Never hand-parse a colour — passing the computed string
through means oklch(), color-mix() and any rebranded palette all work, and
light/dark comes for free. Alpha lives in globalAlpha, never in the string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes. The root is the only place a consumer sets height, radius or border.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
decoration with no pointer behaviour of its own; children stay fully
interactive, selectable, focusable and above the rain. Nothing here traps
scroll or focus.
Customization levers
- Weather: `wind` is the headline knob — 0 is dead calm, 0.15 a breeze, 0.9 a
squall, negative blows the other way. Pair it with `speed` (1.6 for a storm)
and `density` for the intensity.
- Depth feel: the eight ramp constants (FAR/NEAR fall, width, alpha, shear
weight) are the whole look. Widen the alpha or speed gap for dramatic depth,
narrow it for a flat drizzle. layers=1 collapses to a single sheet — the
restrained version to put behind a form.
- Streak character: EXPOSURE (0.038s) is the shutter — lower it for hard short
dashes, raise it toward 0.08 for long silk threads. SHEAR_JITTER is the
per-drop angle noise that stops a sheet reading as a ruled hatch.
- Ground: `ground` lifts the impact line (0.62 puts it under a headline);
`splash={false}` removes ripples entirely. SPLASH_SQUASH controls how flat
the ground plane looks, SPLASH_RADIUS and SPLASH_LIFE how big and how long,
SPLASH_CHANCE and the 32-slot pool how busy.
- Palette: add a tone entry pointing at any token — var(--primary-foreground)
is the right ink over an inverted panel, var(--chart-2) for a branded shower.
- Cost: lower MAX_TOTAL_DROPS or raise MIN_AREA_PER_DROP to make the caps
bite sooner; drop MAX_DPR to 1 to halve stroke cost on retina if you ship
very large heroes.Concepts
- Shutter streak length — a streak is not a styled dash, it is distance travelled while an imaginary shutter is open: vertical extent = fall speed × 0.038s. Near sheets are faster, so they are longer without a second constant, and a sheared drop draws a longer segment than a vertical one exactly as wind does in reality.
- Wind as shear, with a spawn domain that follows it — wind is a horizontal-to-vertical velocity ratio, so it tilts the rain instead of translating it. The respawn x range is shifted by the full-fall travel,
[min(0, -travel), width + |travel|], which is what stops a triangular bald patch from opening on the upwind edge a few seconds after the wind starts. - Per-sheet batching — alpha and line width are constants of the sheet, which is why a whole sheet is one path and one
stroke(). Per-drop variety is moved into length, angle and position, where it is free. Depth cues cost three state changes per frame, not three hundred. - Ripple ring buffer — impacts claim one of 32 pre-built ripple objects through a rotating cursor, so nothing is allocated in the loop and the pool doubles as the rate limiter: when impacts outrun it, the oldest ring is overwritten and visibly clipped, which beats an unbounded array every time.
- Implied ground line — nothing is painted for the floor. Drops simply recycle when they cross
ground × heightand ripples expand along it as ellipses squashed to 0.32 — the squash is the entire ground-plane illusion, and lifting the line puts the rain in front of a headline instead of behind it. - Composed still frame — under
prefers-reduced-motion: reduce, and equally atspeed = 0, the loop never starts and one deliberate frame is drawn: a full curtain plus six ripples frozen at staggered ages, so the impact line still reads. Motion off must not mean an empty box.
Matrix Rain
Falling glyph columns on one canvas — every column keeps its own speed, length and bright head, and glyphs swap in place so the stream reads as changing rather than scrolling.
Snow
Canvas snowfall with per-flake sway, a depth ramp from soft foreground bokeh to crisp distant specks, and an optional drift that builds along the bottom edge and settles.