Backgrounds
MonsoonGlass
A fogged window at night. Beads gather, grow heavy and run, clearing trails the fog slowly takes back. Drag the pointer to wipe the glass and see the city sharp.
Download
- React
- Tailwind CSS v4
components/ui/monsoon-glass.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type MonsoonGlassProps = {
children?: ReactNode;
className?: string;
/** Colours of the city lights behind the glass. */
lights?: string[];
/** Base colour of the night behind the glass. */
background?: string;
/** 1 is a steady monsoon; 2 is twice the drops. */
rain?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
type Bead = { x: number; y: number; r: number };
type Runner = Bead & { v: number; hold: number; trail: number; bend: number };
const LIGHTS = ["#ffb347", "#ff5e62", "#7ee0ff", "#ffe08a"];
const rand = (a: number, b: number) => a + Math.random() * (b - a);
function sheet(w: number, h: number) {
const c = document.createElement("canvas");
c.width = Math.max(1, Math.round(w));
c.height = Math.max(1, Math.round(h));
return c;
}
/* The city: dark blocks with tiny lit windows, then soft street lights over them. The window
detail is what fog hides and a wipe gives back. */
function paintCity(c: HTMLCanvasElement, background: string, lights: string[]) {
const g = c.getContext("2d");
if (!g) return;
const { width: w, height: h } = c;
g.fillStyle = background;
g.fillRect(0, 0, w, h);
for (let x = rand(-30, 0); x < w; ) {
const bw = rand(w * 0.07, w * 0.17);
const top = h - rand(h * 0.3, h * 0.72);
g.fillStyle = "#050a12";
g.fillRect(x, top, bw, h - top);
for (let wy = top + 8; wy < h; wy += 9) {
for (let wx = x + 5; wx < x + bw - 5; wx += 7) {
if (Math.random() > 0.3) continue;
g.globalAlpha = rand(0.45, 1);
g.fillStyle = lights[Math.floor(Math.random() * lights.length)];
g.fillRect(wx, wy, 3, 4);
}
}
g.globalAlpha = 1;
x += bw + rand(0, 10);
}
g.globalCompositeOperation = "lighter";
for (let i = 0; i < 30; i++) {
const x = rand(0, w);
const y = rand(h * 0.4, h);
const r = rand(10, Math.min(w, h) * 0.1);
const glow = g.createRadialGradient(x, y, 0, x, y, r);
const color = lights[Math.floor(Math.random() * lights.length)];
glow.addColorStop(0, color);
glow.addColorStop(1, `${color}00`);
g.globalAlpha = rand(0.25, 0.65);
g.fillStyle = glow;
g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.fill();
}
g.globalAlpha = 1;
g.globalCompositeOperation = "source-over";
}
/* One water bead: a drop is a tiny lens, so it is lit from below and dark on top. */
function paintBead() {
const c = sheet(48, 48);
const g = c.getContext("2d");
if (!g) return c;
const body = g.createRadialGradient(24, 31, 2, 24, 24, 23);
body.addColorStop(0, "rgba(255,255,255,0.6)");
body.addColorStop(0.65, "rgba(220,235,245,0.16)");
body.addColorStop(1, "rgba(0,8,18,0.5)");
g.fillStyle = body;
g.beginPath();
g.arc(24, 24, 23, 0, Math.PI * 2);
g.fill();
g.fillStyle = "rgba(255,255,255,0.85)";
g.beginPath();
g.arc(17, 15, 3.5, 0, Math.PI * 2);
g.fill();
return c;
}
/* MonsoonGlass: a fogged window at night. Beads gather, grow heavy and run, clearing a trail the
fog slowly takes back. Drag the pointer to wipe the glass and see the city sharp. */
export function MonsoonGlass({
children,
className = "",
lights = LIGHTS,
background = "#0a1524",
rain = 1,
speed = 1,
}: MonsoonGlassProps) {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const bead = paintBead();
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let refill = 0;
let wipeX: number | null = null;
let wipeY = 0;
let scene = sheet(1, 1);
let frost = sheet(1, 1);
let fog = sheet(1, 1);
let f = fog.getContext("2d");
let beads: Bead[] = [];
let runners: Runner[] = [];
const clearFog = (x0: number, y0: number, x1: number, y1: number, width: number) => {
if (!f) return;
f.lineCap = "round";
f.lineWidth = width;
f.beginPath();
f.moveTo(x0, y0);
f.lineTo(x1, y1);
f.stroke();
};
const step = (dt: number) => {
if (!f) return;
// The breath on the glass comes back in small coats, not every frame.
refill += dt;
if (refill > 0.25) {
refill = 0;
f.globalCompositeOperation = "source-over";
f.globalAlpha = 0.08;
f.drawImage(frost, 0, 0, w, h);
f.globalAlpha = 1;
}
f.globalCompositeOperation = "destination-out";
const most = Math.min(800, (w * h * rain) / 1300);
for (let n = (most / 5) * dt; n > 0; n--) {
if (n < 1 && Math.random() > n) break;
beads.push({ x: rand(0, w), y: rand(0, h), r: rand(1, 3.6) });
}
if (beads.length > most) beads.splice(0, beads.length - most);
if (Math.random() < dt * rain * Math.max(0.7, w / 500)) {
const r = rand(2.6, 5.5);
runners.push({ x: rand(0, w), y: rand(-20, h * 0.5), r, v: 45 + r * 34, hold: rand(0, 0.5), trail: 0, bend: rand(0, 6) });
}
for (const d of runners) {
d.hold -= dt;
if (d.hold > 0) continue;
if (Math.random() < dt * 0.7) d.hold = rand(0.15, 0.6);
const fromX = d.x;
const fromY = d.y;
const move = d.v * dt;
d.y += move;
d.x += Math.sin(d.y * 0.012 + d.bend) * move * 0.1;
clearFog(fromX, fromY, d.x, d.y, d.r * 2.3);
// Running drops drink what they touch and leave small ones behind them.
beads = beads.filter((b) => {
if (Math.hypot(b.x - d.x, b.y - d.y) > d.r + b.r) return true;
d.r = Math.min(7, d.r + b.r * 0.03);
return false;
});
d.trail -= move;
if (d.trail < 0) {
d.trail = rand(12, 34);
beads.push({ x: d.x + rand(-1, 1), y: d.y - d.r * 2, r: rand(0.8, 1.8) });
}
}
runners = runners.filter((d) => d.y < h + 20);
};
const draw = () => {
ctx.drawImage(scene, 0, 0, w, h);
ctx.drawImage(fog, 0, 0, w, h);
for (const b of beads) ctx.drawImage(bead, b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
for (const d of runners) ctx.drawImage(bead, d.x - d.r, d.y - d.r * 1.35, d.r * 2, d.r * 2.7);
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
step(dt);
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (still || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
scene = sheet(w, h);
paintCity(scene, background, lights);
// Shrinking then stretching is a cheap blur that also works where canvas filters do not.
const small = sheet(w / 12, h / 12);
small.getContext("2d")?.drawImage(scene, 0, 0, small.width, small.height);
frost = sheet(w / 2, h / 2);
const fr = frost.getContext("2d");
if (fr) {
fr.imageSmoothingQuality = "high";
fr.drawImage(small, 0, 0, frost.width, frost.height);
fr.fillStyle = "rgba(196,216,232,0.17)";
fr.fillRect(0, 0, frost.width, frost.height);
}
fog = sheet(w / 2, h / 2);
f = fog.getContext("2d");
f?.setTransform(0.5, 0, 0, 0.5, 0, 0);
f?.drawImage(frost, 0, 0, w, h);
beads = [];
runners = [];
// Start mid-storm: the window is already streaked the moment it appears.
for (let i = 0; i < 180; i++) step(0.04);
draw();
};
const wipe = (e: PointerEvent) => {
if (!f) return;
const box = canvas.getBoundingClientRect();
const x = e.clientX - box.left;
const y = e.clientY - box.top;
const reach = Math.min(w, h) * 0.05 + 8;
f.globalCompositeOperation = "destination-out";
clearFog(wipeX ?? x, wipeX === null ? y : wipeY, x, y, reach * 2);
beads = beads.filter((b) => Math.hypot(b.x - x, b.y - y) > reach);
wipeX = x;
wipeY = y;
if (still) draw();
};
const lift = () => {
wipeX = null;
};
// Redraw on size change; only animate while on screen.
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
ro.observe(canvas);
io.observe(canvas);
root.addEventListener("pointermove", wipe);
root.addEventListener("pointerleave", lift);
return () => {
stop();
ro.disconnect();
io.disconnect();
root.removeEventListener("pointermove", wipe);
root.removeEventListener("pointerleave", lift);
};
}, [lights, background, rain, speed]);
return (
<div ref={rootRef} className={`relative overflow-hidden ${className}`} style={{ background }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/monsoon-glass.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/monsoon-glass-demo.tsx
import { MonsoonGlass } from "@/components/ui/monsoon-glass"; export default function MonsoonGlassExample() { return ( <MonsoonGlass className="h-full"> <p className="absolute bottom-5 left-6 text-2xl font-medium text-white/90">Wipe the glass</p> </MonsoonGlass> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| lights | string[] | 4 warm and cyan colours | Colours of the city lights behind the glass. |
| background | string | "#0a1524" | Base colour of the night. |
| rain | number | 1 | 1 is a steady monsoon; 2 is twice the drops. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |