A dark hero section where hundreds of small particles drift along an invisible field of currents and leave glowing trails behind them. The field is made from a few layered sine waves that change slowly over time, so the streams bend, merge and spiral without ever repeating. A vignette keeps the centre calm for a badge, a two line headline, a paragraph and two buttons. Three buttons under the hero switch between ember, aurora and ocean colour themes. On phones the particle count drops with the area and the buttons stack.
// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/flow-field.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
// Colour themes: where the hue starts, how far it spreads, and the background the trails fade into.
const themes = {
ember: { label: "Ember", hueStart: 0, hueRange: 55, saturation: 95, lightness: 58, bg: "10, 5, 3", trail: 0.07, accent: "from-amber-200 via-orange-300 to-rose-300", dot: "bg-orange-400" },
aurora: { label: "Aurora", hueStart: 120, hueRange: 200, saturation: 90, lightness: 62, bg: "5, 5, 8", trail: 0.06, accent: "from-emerald-300 via-teal-200 to-violet-300", dot: "bg-emerald-400" },
ocean: { label: "Ocean", hueStart: 180, hueRange: 90, saturation: 88, lightness: 60, bg: "2, 6, 10", trail: 0.06, accent: "from-cyan-200 via-sky-300 to-indigo-300", dot: "bg-sky-400" },
} as const;
type ThemeName = keyof typeof themes;
// Particles per 1,000 square pixels, capped so phones and large screens both stay smooth.
const DENSITY = 1.1;
const MAX_PARTICLES = 1100;
// Frames simulated before the first paint so the trails are already drawn when the hero appears.
const WARM_UP_FRAMES = 110;
type Particle = { x: number; y: number; speed: number; hue: number; life: number; maxLife: number };
/** Direction of the current at a point. A few layered sine waves that drift with time give an organic field without a noise library. */
function fieldAngle(x: number, y: number, t: number): number {
const s = 0.0025;
return (
Math.sin(x * s + t * 0.0007) * Math.PI +
Math.cos(y * s + t * 0.0005) * Math.PI +
Math.sin((x + y) * s * 0.6 + t * 0.0009) * Math.PI * 0.6 +
Math.cos((x - y) * s * 0.4 + t * 0.0006) * Math.PI * 0.4
);
}
export default function ParticleFlowFieldCanvasBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [theme, setTheme] = useState<ThemeName>("ember");
const reduce = useReducedMotion();
const cfg = themes[theme];
useEffect(() => {
const canvas = canvasRef.current;
const wrap = canvas?.parentElement;
const ctx = canvas?.getContext("2d");
if (!canvas || !wrap || !ctx) return;
const c = themes[theme];
let w = 0;
let h = 0;
let time = 0;
let frame = 0;
let onScreen = true;
let particles: Particle[] = [];
const spawn = (): Particle => {
const maxLife = 200 + Math.floor(Math.random() * 300);
return { x: Math.random() * w, y: Math.random() * h, speed: 1.1 + Math.random() * 1.8, hue: c.hueStart + Math.random() * c.hueRange, life: Math.floor(Math.random() * maxLife), maxLife };
};
const step = () => {
time++;
// Painting the background at low opacity fades older dots, which is what leaves the trails.
ctx.fillStyle = `rgba(${c.bg}, ${c.trail})`;
ctx.fillRect(0, 0, w, h);
for (const p of particles) {
const angle = fieldAngle(p.x, p.y, time);
p.x += Math.cos(angle) * p.speed;
p.y += Math.sin(angle) * p.speed;
p.life++;
if (p.life > p.maxLife) {
p.x = Math.random() * w;
p.y = Math.random() * h;
p.life = 0;
p.hue = c.hueStart + Math.random() * c.hueRange;
continue;
}
if (p.x < 0) p.x += w;
else if (p.x > w) p.x -= w;
if (p.y < 0) p.y += h;
else if (p.y > h) p.y -= h;
const progress = p.life / p.maxLife;
const alpha = Math.min(progress * 8, 1) * Math.min((1 - progress) * 6, 1) * 0.9;
// The hue leans a little with the direction of travel, so neighbouring streams differ.
const hue = (p.hue + Math.sin(angle) * c.hueRange * 0.25 + 360) % 360;
ctx.fillStyle = `hsla(${hue.toFixed(0)}, ${c.saturation}%, ${c.lightness}%, ${alpha.toFixed(2)})`;
ctx.fillRect(p.x - 1.2, p.y - 1.2, 2.4, 2.4);
}
};
const loop = () => {
step();
frame = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(frame);
if (reduce || !onScreen || document.hidden) return;
frame = requestAnimationFrame(loop);
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = wrap.clientWidth;
h = wrap.clientHeight;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = `rgb(${c.bg})`;
ctx.fillRect(0, 0, w, h);
particles = Array.from({ length: Math.min(MAX_PARTICLES, Math.round((w * h * DENSITY) / 1000)) }, spawn);
for (let i = 0; i < WARM_UP_FRAMES; i++) step();
};
const sizeWatcher = new ResizeObserver(resize);
sizeWatcher.observe(wrap);
const viewWatcher = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
start();
});
viewWatcher.observe(wrap);
document.addEventListener("visibilitychange", start);
resize();
start();
return () => {
cancelAnimationFrame(frame);
sizeWatcher.disconnect();
viewWatcher.disconnect();
document.removeEventListener("visibilitychange", start);
};
}, [theme, reduce]);
const rise = (delay: number) => ({
initial: reduce ? false : ({ opacity: 0, y: 20 } as const),
animate: { opacity: 1, y: 0 },
transition: { duration: 0.8, delay, ease: [0.22, 0.61, 0.36, 1] as const },
});
return (
<section className="relative flex min-h-[680px] w-full items-center justify-center overflow-hidden px-4 py-20" style={{ background: `rgb(${cfg.bg})` }}>
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" />
{/* Vignette: keeps the middle calm for the copy and lets the edges glow. */}
<div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(ellipse 60% 55% at 50% 48%, rgba(${cfg.bg}, 0.88) 0%, rgba(${cfg.bg}, 0.55) 55%, rgba(${cfg.bg}, 0) 100%)` }} aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-32" style={{ background: `linear-gradient(to bottom, rgb(${cfg.bg}), transparent)` }} aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-32" style={{ background: `linear-gradient(to top, rgb(${cfg.bg}), transparent)` }} aria-hidden="true" />
<div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center text-center">
<motion.p {...rise(0.1)} className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3.5 py-1.5 text-xs font-medium text-white backdrop-blur">
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot}`} aria-hidden="true" />
Product analytics for small teams
</motion.p>
<motion.h1 {...rise(0.2)} className="mt-6 text-4xl font-bold leading-[1.08] tracking-tight text-white sm:text-6xl md:text-7xl">
Follow the current,
<br />
<span className={`bg-gradient-to-r bg-clip-text text-transparent ${cfg.accent}`}>not the dashboard</span>
</motion.h1>
<motion.p {...rise(0.35)} className="mt-6 max-w-lg text-base leading-relaxed text-neutral-200 sm:text-lg">
Estuary traces every visit from first click to checkout and shows you the exact step where the stream slows down.
</motion.p>
<motion.div {...rise(0.5)} className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-full bg-white px-7 text-sm font-semibold text-neutral-950 transition hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:w-auto">
Start tracing free
</a>
<a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-full border border-white/30 px-7 text-sm font-semibold text-white transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:w-auto">
Watch a two minute tour
</a>
</motion.div>
<motion.div {...rise(0.7)} className="mt-12 flex items-center gap-1 rounded-full border border-white/15 bg-black/40 p-1 backdrop-blur" role="group" aria-label="Background colour theme">
{(Object.keys(themes) as ThemeName[]).map((name) => (
<button
key={name}
type="button"
aria-pressed={theme === name}
onClick={() => setTheme(name)}
className={`inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white ${theme === name ? "bg-white text-neutral-950" : "text-neutral-200 hover:bg-white/10"}`}
>
<span className={`h-2 w-2 rounded-full ${themes[name].dot}`} aria-hidden="true" />
{themes[name].label}
</button>
))}
</motion.div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/particle-flow-field-canvas-background.json
It is a rule that gives every point on the screen a direction. Each particle looks up the direction under it, takes a small step that way and repeats. Because nearby points have similar directions, particles gather into streams.
The canvas is never cleared. Each frame a rectangle of the background colour is painted over everything at low opacity, which dims older dots a little, and then the particles are drawn at their new positions.
Before the first paint the component runs about a hundred frames of the simulation in one go. `WARM_UP_FRAMES` controls that number.
No. Four sine and cosine terms with different scales and speeds are enough to look organic, and they are much cheaper than Perlin or simplex noise.
Each frame is one loop over the particles with a little trigonometry and a small filled square per particle. The count is capped and the device pixel ratio is limited to 2, which keeps it smooth on mid range phones.
Adapted from Flow Field in Kokonut UI, used under its open-source licence. Licences and credits
Related: Particle Network Background with Connecting Lines, Starfield Background with Depth and Shooting Stars, Soft Light Beams Canvas Background.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.