"use client";
import * as React from "react";
export type WeatherType = "rain" | "snow" | "sleet";
export interface WeatherEffectProps extends React.ComponentPropsWithoutRef<"div"> {
/** Rain streaks, drifting snow, or sleet (short streaks mixed with ice pellets). */
type?: WeatherType;
/** Density of the precipitation, 0.1–1. Scales with the container's area. */
intensity?: number;
/** Wind, -1 (blowing left) to 1 (blowing right). */
wind?: number;
/** Fall speed multiplier. */
speed?: number;
/** Moving a mouse or pen pointer pushes nearby particles along with it. */
gust?: boolean;
/** Raindrops and pellets splash at the bottom edge. */
splash?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const rgba = ([r, g, b]: RGB, a: number) => `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`;
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** mulberry32: a tiny seeded PRNG, so the reduced-motion still is the same on every visit. */
function seeded(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* -------------------------------- Particles -------------------------------- */
const STEP_MS = 1000 / 60;
/** Particles per 100,000 px² at intensity 1, by kind of weather. */
const DENSITY: Record<WeatherType, number> = { rain: 120, snow: 64, sleet: 96 };
const MAX_PARTICLES = 1100;
const MAX_SPLASHES = 320;
const GUST_RADIUS = 200;
const FLAKE_SPRITE = 32;
type Kind = "drop" | "flake" | "pellet";
interface Particle {
kind: Kind;
x: number;
y: number;
vx: number;
vy: number;
/** 0 = far, 1 = near: size, speed, opacity and whether it splashes. */
z: number;
/** Sway phase and rate (snow). */
phase: number;
rate: number;
}
interface Splash {
x: number;
y: number;
vx: number;
vy: number;
life: number;
/** Rings spread flat on the ground; droplets arc up and fall. */
ring: boolean;
size: number;
}
/** A soft round flake, rendered once per theme and drawn scaled. */
function renderFlake(ink: RGB) {
const canvas = document.createElement("canvas");
canvas.width = FLAKE_SPRITE;
canvas.height = FLAKE_SPRITE;
const ctx = canvas.getContext("2d");
if (!ctx) return canvas;
const c = FLAKE_SPRITE / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
g.addColorStop(0, rgba(ink, 1));
g.addColorStop(0.45, rgba(ink, 0.85));
g.addColorStop(0.75, rgba(ink, 0.25));
g.addColorStop(1, rgba(ink, 0));
ctx.fillStyle = g;
ctx.fillRect(0, 0, FLAKE_SPRITE, FLAKE_SPRITE);
return canvas;
}
/* -------------------------------- Component -------------------------------- */
/**
* Rain, snow or sleet falling over content. Rain streaks lean with the wind and splash at the
* bottom, snow drifts and sways at different depths, and a moving pointer pushes nearby
* particles in a gentle gust. The particle count scales with the container's area (fewer on
* small screens); the canvas pauses offscreen and in hidden tabs, and reduced motion draws a
* still frame.
*/
export function WeatherEffect({
type = "rain",
intensity = 0.5,
wind = 0.25,
speed = 1,
gust = true,
splash = true,
className = "",
children,
...props
}: WeatherEffectProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const density = Math.min(1, Math.max(0.1, intensity));
const windX = Math.min(1, Math.max(-1, wind));
const fall = Math.min(3, Math.max(0.1, speed));
const rnd = reduceMotion ? seeded(97 + Math.round(density * 100)) : Math.random;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let pending = 0;
let time = 0;
let inView = false;
let dark = true;
let ink: RGB = [148, 163, 184];
let flake: HTMLCanvasElement | null = null;
let particles: Particle[] = [];
const splashes: Splash[] = [];
// Pointer position and its smoothed horizontal velocity (px per step), for the gust.
const ptr = { x: 0, y: 0, vx: 0, lastX: 0, inside: false, touch: false, seen: false };
const readTheme = () => {
const probed = resolveColor(getComputedStyle(canvas).color, [148, 163, 184]);
dark = luminance(probed) > 0.5;
ink = probed;
// Flakes on a light ground read as soft grey-blue rather than dark dots.
flake = renderFlake(dark ? ink : (ink.map((v) => Math.round(v + (255 - v) * 0.3)) as RGB));
};
const kindFor = (): Kind => (type === "snow" ? "flake" : type === "sleet" && rnd() < 0.45 ? "pellet" : "drop");
/** Terminal fall speed in px per step. */
const fallSpeed = (p: Particle) =>
(p.kind === "drop" ? (type === "sleet" ? 7 + 6 * p.z : 9 + 10 * p.z) : p.kind === "pellet" ? 5 + 4 * p.z : 0.45 + 1.05 * p.z) * fall;
/** Horizontal drift the wind gives a particle: heavy drops lean less than flakes. */
const windDrift = (p: Particle) => windX * (p.kind === "flake" ? 0.7 + 1.6 * p.z : fallSpeed(p) * 0.55);
/** Places a particle above the top edge, upwind enough that wind-blown ones cover the whole box. */
const spawn = (p: Particle, anywhere: boolean) => {
const lean = height * (windDrift(p) / Math.max(0.3, fallSpeed(p)));
p.x = rnd() * (width + Math.abs(lean)) - Math.max(0, lean);
p.y = anywhere ? rnd() * height : -10 - rnd() * 60;
p.vx = windDrift(p);
p.vy = fallSpeed(p);
};
const populate = () => {
const area = width * height;
const small = width < 640 ? 0.7 : 1; // adaptive: phones get a lighter field
const count = Math.min(MAX_PARTICLES, Math.max(8, Math.round((area / 100_000) * DENSITY[type] * density * small)));
particles = Array.from({ length: count }, () => {
const p: Particle = { kind: kindFor(), x: 0, y: 0, vx: 0, vy: 0, z: rnd(), phase: rnd() * Math.PI * 2, rate: 0.6 + rnd() * 0.9 };
spawn(p, true);
return p;
});
splashes.length = 0;
};
const addSplash = (p: Particle, groundY: number) => {
if (!splash || splashes.length > MAX_SPLASHES) return;
if (p.kind === "drop") {
splashes.push({ x: p.x, y: groundY, vx: 0, vy: 0, life: 1, ring: true, size: 4 + p.z * 7 });
const n = 1 + Math.floor(rnd() * 3);
for (let i = 0; i < n; i++) {
splashes.push({ x: p.x, y: groundY, vx: (rnd() - 0.5) * 2.4 + p.vx * 0.15, vy: -(1.4 + rnd() * 2) * (0.6 + 0.5 * p.z), life: 1, ring: false, size: 1 + p.z * 0.6 });
}
} else {
// Ice pellets bounce once.
splashes.push({ x: p.x, y: groundY, vx: p.vx * 0.3 + (rnd() - 0.5) * 1.5, vy: -(2 + rnd() * 2) * (0.5 + 0.5 * p.z), life: 1, ring: false, size: 1.2 + p.z });
}
};
/** One fixed 60Hz step. */
const step = () => {
time += STEP_MS / 1000;
ptr.vx *= 0.9;
for (const p of particles) {
let targetVx = windDrift(p);
if (p.kind === "flake") targetVx += Math.sin(time * p.rate + p.phase) * (0.35 + 0.5 * p.z); // sway
// Gust: particles near a moving pointer are pushed along with it, lighter ones more.
if (gust && ptr.inside && Math.abs(ptr.vx) > 0.05) {
const dx = p.x - ptr.x;
const dy = p.y - ptr.y;
const d2 = dx * dx + dy * dy;
if (d2 < GUST_RADIUS * GUST_RADIUS) {
const falloff = 1 - Math.sqrt(d2) / GUST_RADIUS;
p.vx += ptr.vx * falloff * (p.kind === "flake" ? 0.06 : 0.025);
}
}
p.vx += (targetVx - p.vx) * (p.kind === "flake" ? 0.02 : 0.08);
p.vy += (fallSpeed(p) - p.vy) * 0.1;
p.x += p.vx;
p.y += p.vy;
// Near drops and pellets land on a shallow ground band at the bottom; far ones fall past.
const lands = p.kind !== "flake" && p.z > 0.5;
const groundY = height - 2 - (1 - p.z) * 22;
if (lands && p.y >= groundY) {
addSplash(p, groundY);
spawn(p, false);
} else if (p.y > height + 40) spawn(p, false);
else if (p.x < -60) p.x += width + 120;
else if (p.x > width + 60) p.x -= width + 120;
}
for (let i = splashes.length - 1; i >= 0; i--) {
const s = splashes[i];
if (s.ring) s.life -= 0.06;
else {
s.vy += 0.22;
s.x += s.vx;
s.y += s.vy;
s.life -= 0.035;
}
if (s.life <= 0) splashes.splice(i, 1);
}
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.lineCap = "round";
// Streaks in three depth buckets, one path each: far ones thin and faint.
const buckets = [new Path2D(), new Path2D(), new Path2D()];
const pellets = new Path2D();
for (const p of particles) {
if (p.kind === "drop") {
const v = Math.hypot(p.vx, p.vy) || 1;
const len = (type === "sleet" ? 0.9 : 1.5) * v * (0.7 + 0.6 * p.z);
const path = buckets[Math.min(2, Math.floor(p.z * 3))];
path.moveTo(p.x, p.y);
path.lineTo(p.x - (p.vx / v) * len, p.y - (p.vy / v) * len);
} else if (p.kind === "pellet") {
const r = 0.9 + p.z * 1.1;
pellets.moveTo(p.x + r, p.y);
pellets.arc(p.x, p.y, r, 0, Math.PI * 2);
} else if (flake) {
const s = (1.6 + p.z * 4.6) * 2;
ctx.globalAlpha = dark ? 0.35 + 0.55 * p.z : 0.3 + 0.5 * p.z;
ctx.drawImage(flake, p.x - s / 2, p.y - s / 2, s, s);
}
}
ctx.globalAlpha = 1;
const alphas = dark ? [0.16, 0.28, 0.46] : [0.2, 0.34, 0.55];
buckets.forEach((path, i) => {
ctx.strokeStyle = rgba(ink, alphas[i]);
ctx.lineWidth = 0.7 + i * 0.3;
ctx.stroke(path);
});
ctx.fillStyle = rgba(ink, dark ? 0.75 : 0.7);
ctx.fill(pellets);
for (const s of splashes) {
if (s.ring) {
const r = s.size * (1 - s.life * 0.7);
ctx.beginPath();
ctx.ellipse(s.x, s.y, r, r * 0.26, 0, 0, Math.PI * 2);
ctx.strokeStyle = rgba(ink, 0.4 * s.life);
ctx.lineWidth = 0.8;
ctx.stroke();
} else {
ctx.fillStyle = rgba(ink, 0.65 * s.life);
ctx.fillRect(s.x - s.size / 2, s.y - s.size / 2, s.size, s.size);
}
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
if (ptr.inside && ptr.seen) {
// Turn pointer movement since the last frame into a gust velocity (px per 60Hz step).
const moved = ptr.x - ptr.lastX;
ptr.vx = Math.max(-24, Math.min(24, ptr.vx * 0.5 + (moved / Math.max(1, dt / STEP_MS)) * 0.5));
}
ptr.lastX = ptr.x;
ptr.seen = ptr.inside;
// Fixed 60Hz steps: the fall speed is the same on 60, 120 or 144Hz displays. A short frame
// may run no step and just redraws the same state; precipitation never settles.
pending = Math.min(pending + dt, STEP_MS * 4);
let stepped = false;
while (pending >= STEP_MS) {
step();
pending -= STEP_MS;
stepped = true;
}
if (stepped) draw();
schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
populate();
draw();
schedule();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
};
// Touch has no hover: a finger stirs the air only while pressed.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = true;
ptr.inside = locate(e);
ptr.seen = false;
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
};
const onPointerLeave = () => (ptr.inside = false);
const onVisibility = () => (document.hidden ? stop() : schedule());
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
// Pointer listeners only exist while the canvas is on screen.
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
const listenPointer = gust && !reduceMotion;
let listening = false;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (listenPointer && inView !== listening) {
listening = inView;
listen(inView);
}
if (inView) schedule();
else stop();
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
if (listening) listen(false);
};
}, [type, intensity, wind, speed, gust, splash, reduceMotion]);
return (
<div ref={rootRef} className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-slate-500 dark:text-slate-200"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}