Backgrounds

Rain

Layered canvas rainfall — parallax sheets of streaks sheared by a wind prop, landing as ripples on an implied ground line.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type RainTone = "foreground" | "primary" | "muted"

/**
 * Streak ink per tone — semantic tokens only. The value is written onto the
 * canvas as an inline `color`, then the *computed* string is read back and
 * handed to the 2D context verbatim. Any syntax the browser resolves
 * (oklch(), color-mix(), a brand colour the consumer put behind the token)
 * works, and nothing here ever parses or hard-codes a colour.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/rain.json

Prompt

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 × height and 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 at speed = 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.

On This Page