← All particles

Rain Streaks

Rain falling at an angle, with each streak as long as the drop is fast.

environmentalcalmminimal140 particles · light · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

222 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Rain Streaks
 *
 * Rain crossing the frame at an angle, with near drops streaking long
 * and far ones barely at all.
 *
 * The technique that keeps it honest: a streak is drawn as the drop's
 * displacement over a fixed shutter time — the same line a camera would
 * record. Length is therefore derived from speed rather than being a
 * separate tunable, so a fast drop can never be short and a slow one can
 * never smear. Change the fall speed, the angle or the variant and the
 * streaks follow without anything else needing to be retuned.
 *
 * Supporting detail: drops are grouped into four depth layers, so the
 * whole field draws in four strokes instead of one per drop.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `angle`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RainStreaksProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Drops in the air at once, across all depths. */
  count?: number;
  /** Lean off vertical, in degrees. Positive leans right. */
  angle?: number;
  /** Streak colour. */
  color?: string;
};

type VariantConfig = {
  /** Multiplier applied to `count`. */
  density: number;
  /** Multiplier on fall speed — and therefore on streak length. */
  speed: number;
  /** Shutter time in seconds. The streak is this long in travel. */
  shutter: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Light rain against a window, well behind the content.
  subtle: { density: 0.6, speed: 0.75, shutter: 0.045 },
  // Steady rain. All-purpose.
  default: { density: 1, speed: 1, shutter: 0.055 },
  // A downpour: faster, denser, and the streaks lengthen with it.
  playful: { density: 1.4, speed: 1.35, shutter: 0.062 },
};

type Drop = { x: number; y: number };

type Layer = {
  drops: Drop[];
  /** Fall speed in px per second at this depth. */
  speed: number;
  alpha: number;
  width: number;
};

/** Depth bands. Four is enough to read as depth and cheap to draw. */
const LAYERS = 4;
const NEAR_SPEED = 620;
const FAR_SPEED = 250;

export default function RainStreaks({
  variant = "default",
  count = 140,
  angle = 14,
  color = "#8FB4D9",
}: RainStreaksProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const total = Math.max(8, Math.round(count * config.density));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 1;
    let layers: Layer[] = [];

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
    };

    const build = () => {
      layers = [];
      for (let index = 0; index < LAYERS; index++) {
        // 0 is the farthest band, 1 the nearest.
        const depth = index / (LAYERS - 1);
        const share = Math.round(total / LAYERS);
        layers.push({
          speed: (FAR_SPEED + (NEAR_SPEED - FAR_SPEED) * depth) * config.speed,
          alpha: 0.16 + depth * 0.4,
          width: 0.6 + depth * 1,
          drops: Array.from({ length: share }, () => ({
            x: Math.random() * (width + 120) - 60,
            y: Math.random() * height,
          })),
        });
      }
    };

    const drawFrame = (lean: number) => {
      context.clearRect(0, 0, width, height);
      context.strokeStyle = color;
      context.lineCap = "round";

      const tilt = (lean * Math.PI) / 180;
      const across = Math.sin(tilt);
      const down = Math.cos(tilt);

      for (const layer of layers) {
        // The streak: where this drop was one shutter ago.
        const travel = layer.speed * config.shutter;
        const backX = across * travel;
        const backY = down * travel;

        context.globalAlpha = layer.alpha;
        context.lineWidth = layer.width;
        context.beginPath();
        for (const drop of layer.drops) {
          context.moveTo(drop.x - backX, drop.y - backY);
          context.lineTo(drop.x, drop.y);
        }
        context.stroke();
      }
      context.globalAlpha = 1;
    };

    const advance = (delta: number, lean: number) => {
      const tilt = (lean * Math.PI) / 180;
      const across = Math.sin(tilt);
      const down = Math.cos(tilt);
      for (const layer of layers) {
        const step = layer.speed * delta;
        for (const drop of layer.drops) {
          drop.x += across * step;
          drop.y += down * step;
          if (drop.y > height + 8) {
            drop.y = -Math.random() * 40 - 8;
            drop.x = Math.random() * (width + 120) - 60;
          }
          if (drop.x > width + 60) drop.x -= width + 120;
          if (drop.x < -60) drop.x += width + 120;
        }
      }
    };

    resize();
    build();

    // Reduced motion: the same frame the shutter would catch, held.
    // Rain is legible as a still image — that is what a photograph of
    // rain is — so nothing about the scene is lost.
    if (reduced) {
      drawFrame(angle);
      const onResizeStill = () => {
        resize();
        build();
        drawFrame(angle);
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;

      // A slow shift in the wind, a couple of degrees either side. Any
      // more and the whole field looks like it is being swung about.
      const lean = angle + Math.sin(elapsed * 0.21) * 2.5;
      advance(delta, lean);
      drawFrame(lean);
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      build();
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, angle, color]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
    />
  );
}

About this effect

Weather behind a screen — a forecast header, a travel card, a mood setter for a quiet empty state. A streak is drawn as the drop's displacement over a fixed shutter time, exactly the line a camera would record, so length is derived from speed rather than tuned separately: a fast drop can never be short and a slow one can never smear. Four depth bands give the field parallax, with far drops thin, slow and faint and near ones long and bright, and each band draws in a single stroke so the whole thing costs four draw calls. The wind leans by a couple of degrees over time, which is enough to feel like weather without looking like the field is being swung about.

Weather forecast headerTravel or commute screenAtmospheric empty stateStorm alert backdrop

Related effects