Backgrounds
FireflyDusk
Palms against a last orange sky. Fireflies flash on their own clocks, nudge each other and fall into step until the whole field flashes as one. The pointer breaks the rhythm.
Download
- React
- Tailwind CSS v4
components/ui/firefly-dusk.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type FireflyDuskProps = {
children?: ReactNode;
className?: string;
/** Number of fireflies. */
count?: number;
color?: string;
/** Colour of the land and palms against the sky. */
ground?: string;
/** How strongly they pull each other into step; 0 never syncs. */
sync?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
type Fly = { x: number; y: number; heading: number; pace: number; phase: number; glow: number };
/* Seconds between one firefly's flashes. */
const PERIOD = 1.3;
/* A breeze scatters the rhythm this often, so the fall into step keeps happening. */
const BREEZE = 34;
const rand = (a: number, b: number) => a + Math.random() * (b - a);
function palm(g: CanvasRenderingContext2D, x: number, base: number, height: number, lean: number) {
const topX = x + lean;
const topY = base - height;
g.lineCap = "round";
g.lineWidth = height * 0.03;
g.beginPath();
g.moveTo(x, base);
g.quadraticCurveTo(x + lean * 0.1, base - height * 0.6, topX, topY);
g.stroke();
for (let i = 0; i < 11; i++) {
const a = -Math.PI + (i / 10) * Math.PI + rand(-0.08, 0.08);
const size = height * rand(0.3, 0.4);
const at = (t: number) => [topX + Math.cos(a) * size * t, topY + Math.sin(a) * size * t + size * 0.75 * t * t];
g.lineWidth = height * 0.008;
g.beginPath();
g.moveTo(topX, topY);
for (let t = 0.1; t <= 1; t += 0.1) g.lineTo(at(t)[0], at(t)[1]);
g.stroke();
g.lineWidth = height * 0.004;
g.beginPath();
for (let t = 0.08; t < 1; t += 0.035) {
const [px, py] = at(t);
const drop = size * 0.17 * (1 - t * 0.6);
g.moveTo(px, py);
g.lineTo(px - drop * 0.3, py + drop);
g.moveTo(px, py);
g.lineTo(px + drop * 0.3, py + drop);
}
g.stroke();
}
}
/* Sky, far hills, palms and grass, painted once: only the fireflies move over it. */
function paintDusk(c: HTMLCanvasElement, w: number, h: number, ground: string) {
const g = c.getContext("2d");
if (!g) return;
const sky = g.createLinearGradient(0, 0, 0, h * 0.8);
sky.addColorStop(0, "#14132f");
sky.addColorStop(0.45, "#3a2452");
sky.addColorStop(0.78, "#9c4a5e");
sky.addColorStop(1, "#f0935a");
g.fillStyle = sky;
g.fillRect(0, 0, w, h);
const hill = (top: number, swell: number, color: string) => {
g.fillStyle = color;
g.beginPath();
g.moveTo(0, h);
for (let x = 0; x <= w + 8; x += 8) g.lineTo(x, top + Math.sin(x * 0.006 + top) * swell + Math.sin(x * 0.017) * swell * 0.3);
g.lineTo(w, h);
g.fill();
};
hill(h * 0.76, h * 0.03, "#3b2340");
hill(h * 0.82, h * 0.025, ground);
g.strokeStyle = ground;
const tall = Math.min(h * 0.72, w * 0.6);
for (const [x, size, lean] of [[0.12, 1, 0.09], [0.21, 0.72, -0.07], [0.84, 0.86, -0.1], [0.93, 0.62, 0.05]]) {
palm(g, w * x, h * 0.86, tall * size, tall * size * lean);
}
g.lineWidth = 1.2;
g.beginPath();
for (let x = 0; x < w; x += 2.5) {
const blade = rand(h * 0.03, h * 0.09);
g.moveTo(x, h);
g.quadraticCurveTo(x, h - blade * 0.6, x + rand(-8, 8), h - blade);
}
g.stroke();
g.fillStyle = ground;
g.fillRect(0, h * 0.97, w, h * 0.03);
}
/* FireflyDusk: palms against a last orange sky and fireflies over the field. Each flashes on its
own clock and nudges the others, so the swarm falls into step and flashes as one, the way real
swarms do. Move the pointer through them to break the rhythm. */
export function FireflyDusk({
children,
className = "",
count = 70,
color = "#d9ff6e",
ground = "#0d0a18",
sync = 1,
speed = 1,
}: FireflyDuskProps) {
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 scene = document.createElement("canvas");
const spark = document.createElement("canvas");
spark.width = spark.height = 64;
const sg = spark.getContext("2d");
if (sg) {
const glow = sg.createRadialGradient(32, 32, 0, 32, 32, 32);
glow.addColorStop(0, "#fff");
glow.addColorStop(0.12, color);
glow.addColorStop(0.4, `${color}44`);
glow.addColorStop(1, `${color}00`);
sg.fillStyle = glow;
sg.fillRect(0, 0, 64, 64);
}
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let breeze = 0;
let flies: Fly[] = [];
let pointer: { x: number; y: number } | null = null;
const draw = () => {
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(scene, 0, 0, w, h);
ctx.globalCompositeOperation = "lighter";
for (const f of flies) {
const size = 4 + 26 * f.glow;
ctx.globalAlpha = 0.14 + 0.86 * f.glow;
ctx.drawImage(spark, f.x - size / 2, f.y - size / 2, size, size);
}
ctx.globalAlpha = 1;
};
const flash = (dt: number) => {
const nudge = (sync * 0.3) / Math.max(1, flies.length);
for (const f of flies) f.phase += dt / PERIOD;
// One flash brings the rest closer to theirs, the nearer to flashing the more so. Those it
// tips over flash in the same instant, and from then on they are locked together.
const firing = flies.filter((f) => f.phase >= 1);
for (let fly = firing.pop(); fly; fly = firing.pop()) {
fly.phase = 0;
fly.glow = 1;
for (const o of flies) {
if (o.phase >= 1) continue;
o.phase *= 1 + nudge;
if (o.phase >= 1) firing.push(o);
}
}
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
breeze += dt;
if (breeze > BREEZE) {
breeze = 0;
for (const f of flies) f.phase = Math.random();
}
flash(dt);
for (const f of flies) {
f.glow *= Math.exp(-dt * 5.5);
f.heading += rand(-1.6, 1.6) * dt * 2;
// Turn back toward the field near its edges.
if (f.y < h * 0.3) f.heading = rand(0.3, 2.8);
if (f.y > h * 0.95) f.heading = rand(-2.8, -0.3);
f.x = (f.x + Math.cos(f.heading) * f.pace * dt + w) % w;
f.y += Math.sin(f.heading) * f.pace * dt;
if (pointer) {
const dx = f.x - pointer.x;
const dy = f.y - pointer.y;
const d = Math.hypot(dx, dy);
if (d < 90) {
f.x += (dx / (d || 1)) * 130 * dt;
f.y += (dy / (d || 1)) * 130 * dt;
f.phase = Math.random();
}
}
}
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;
for (const c of [canvas, scene]) {
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
paintDusk(scene, w, h, ground);
flies = Array.from({ length: count }, () => ({
x: rand(0, w),
y: rand(h * 0.3, h * 0.95),
heading: rand(0, Math.PI * 2),
pace: rand(10, 24),
phase: Math.random(),
glow: Math.random() * 0.6,
}));
draw();
};
const point = (e: PointerEvent) => {
const box = canvas.getBoundingClientRect();
pointer = { x: e.clientX - box.left, y: e.clientY - box.top };
};
const leave = () => {
pointer = 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);
if (!still) {
root.addEventListener("pointermove", point);
root.addEventListener("pointerleave", leave);
}
return () => {
stop();
ro.disconnect();
io.disconnect();
root.removeEventListener("pointermove", point);
root.removeEventListener("pointerleave", leave);
};
}, [count, color, ground, sync, speed]);
return (
<div ref={rootRef} className={`relative overflow-hidden ${className}`} style={{ background: "#14132f" }}>
<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/firefly-dusk.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/firefly-dusk-demo.tsx
import { FireflyDusk } from "@/components/ui/firefly-dusk"; export default function FireflyDuskExample() { return ( <FireflyDusk className="h-full"> <p className="absolute inset-x-0 top-5 text-center text-2xl font-medium text-white/90">Wait for the flash</p> </FireflyDusk> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| count | number | 70 | Number of fireflies. |
| color | string | "#d9ff6e" | Firefly colour. Use 6-digit hex. |
| ground | string | "#0d0a18" | Colour of the land and palms. |
| sync | number | 1 | How strongly they pull each other into step; 0 never syncs. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |