Backgrounds
ParticleStorm
Particles stream through a slowly shifting wind field and leave swirling trails. Pauses off screen.
Download
- React
- Tailwind CSS v4
components/ui/particle-storm.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type ParticleStormProps = {
children?: ReactNode;
className?: string;
color?: string;
/** Base colour. Also used to fade the trails. */
background?: string;
/** Number of particles. */
count?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
type Particle = { x: number; y: number };
/* ParticleStorm: particles stream through a slowly shifting wind field and leave swirling trails. */
export function ParticleStorm({
children,
className = "",
color = "#a78bfa",
background = "#06050d",
count = 400,
speed = 1,
}: ParticleStormProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let time = 0;
let parts: Particle[] = [];
// Wind direction at a point, in radians.
const angle = (x: number, y: number) => Math.sin(x * 0.004 + time * 0.3) * 2 + Math.cos(y * 0.005 - time * 0.2) * 2;
const spawn = (): Particle => ({ x: Math.random() * w, y: Math.random() * h });
// Still frame: a short stroke along the wind at each particle.
const drawStill = () => {
ctx.fillStyle = background;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = color;
ctx.globalAlpha = 0.6;
ctx.beginPath();
for (const p of parts) {
const a = angle(p.x, p.y);
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x + Math.cos(a) * 8, p.y + Math.sin(a) * 8);
}
ctx.stroke();
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
time += dt;
ctx.globalAlpha = 1 - Math.pow(0.92, dt * 60);
ctx.fillStyle = background;
ctx.fillRect(0, 0, w, h);
ctx.globalAlpha = 0.7;
ctx.strokeStyle = color;
ctx.beginPath();
parts.forEach((p, i) => {
const a = angle(p.x, p.y);
const nx = p.x + Math.cos(a) * 60 * dt;
const ny = p.y + Math.sin(a) * 60 * dt;
ctx.moveTo(p.x, p.y);
ctx.lineTo(nx, ny);
p.x = nx;
p.y = ny;
if (nx < 0 || nx > w || ny < 0 || ny > h || Math.random() < 0.002) parts[i] = spawn();
});
ctx.stroke();
ctx.globalAlpha = 1;
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;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
parts = Array.from({ length: count }, spawn);
drawStill();
};
// 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);
return () => {
stop();
ro.disconnect();
io.disconnect();
};
}, [color, background, count, speed]);
return (
<div 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/particle-storm.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/particle-storm-demo.tsx
import { ParticleStorm } from "@/components/ui/particle-storm"; export default function ParticleStormExample() { return ( <ParticleStorm className="h-full"> <p className="absolute bottom-5 left-6 text-2xl font-medium text-white/90">Into the wind</p> </ParticleStorm> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| color | string | "#a78bfa" | Particle colour. |
| background | string | "#06050d" | Base colour; also fades the trails. |
| count | number | 400 | Number of particles. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |