Firefly Field
A few points drifting in the dark, each flashing on its own rhythm.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Firefly Field
*
* A few points that drift and flash, each on its own rhythm.
*
* The technique: the flash envelope is asymmetric — up in about a tenth
* of a second, down over most of a second — and it sits inside a long
* dark gap. A sine wave on opacity gives you dots that pulse; the fast
* rise and slow decay is what says a light was switched on and is now
* dying away, and the two seconds of nothing in between is what makes a
* flash an event rather than a rhythm. An unlit fly is not drawn at all.
*
* The drift matters too, and the cheap version gets it wrong: the random
* walk is applied to each fly's heading, never to its position. Jittering
* a position looks like noise; letting a heading wander produces long,
* smooth, aimless curves — which is what flight looks like.
*
* Self-contained: one canvas plus a glow sprite it draws once.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FireflyFieldProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many flies. Deliberately few — a swarm is a different effect. */
count?: number;
/** Flash color — this one wants a dark surface behind it. */
color?: string;
};
type VariantConfig = {
/** Flight speed in px per second. */
drift: number;
/** Seconds between one fly's flashes, before per-fly variation. */
period: number;
/** Glow radius in px at full brightness. */
glow: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Long gaps and slow drift: minutes could pass and nothing demands you.
subtle: { drift: 5, period: 4.6, glow: 9 },
// A flash every few seconds somewhere in the frame. All-purpose.
default: { drift: 9, period: 3.4, glow: 11 },
// Livelier flight and a shorter gap, still nowhere near a strobe.
playful: { drift: 15, period: 2.5, glow: 13 },
};
/** Seconds to full brightness. Short, but never instant. */
const RISE = 0.12;
/** Seconds of afterglow. Six times the rise — that ratio is the effect. */
const DECAY = 0.72;
type Fly = {
x: number;
y: number;
heading: number;
speed: number;
period: number;
/** Seconds into its own cycle at time zero. */
offset: number;
scale: number;
};
/** `#RRGGBB` plus an alpha, since gradient stops need rgba. */
function withAlpha(hex: string, alpha: number) {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export default function FireflyField({
variant = "default",
count = 14,
color = "#DCE79B",
}: FireflyFieldProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// One glow, drawn once. A tight bright core inside a wide soft halo
// is what reads as a light source rather than a coloured circle.
const sprite = document.createElement("canvas");
const spriteSize = 48;
sprite.width = spriteSize;
sprite.height = spriteSize;
const spriteContext = sprite.getContext("2d");
if (!spriteContext) return;
const half = spriteSize / 2;
const glow = spriteContext.createRadialGradient(half, half, 0, half, half, half);
glow.addColorStop(0, withAlpha(color, 1));
glow.addColorStop(0.14, withAlpha(color, 0.85));
glow.addColorStop(0.4, withAlpha(color, 0.2));
glow.addColorStop(1, withAlpha(color, 0));
spriteContext.fillStyle = glow;
spriteContext.fillRect(0, 0, spriteSize, spriteSize);
let width = 0;
let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
// Two flies passing should add their light, not clip each other.
context.globalCompositeOperation = "lighter";
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const spawn = (): Fly => ({
x: random(0, width),
y: random(0, height),
heading: random(0, Math.PI * 2),
speed: config.drift * random(0.6, 1.4),
period: config.period * random(0.75, 1.3),
offset: random(0, config.period * 1.3),
scale: random(0.8, 1.25),
});
let flies = Array.from({ length: count }, spawn);
/** Fast rise, long decay, then dark. The shape is the whole effect. */
const brightnessAt = (fly: Fly, time: number) => {
const t = (time + fly.offset) % fly.period;
if (t < RISE) return t / RISE;
if (t < RISE + DECAY) return Math.pow(1 - (t - RISE) / DECAY, 1.9);
return 0;
};
const draw = (fly: Fly, brightness: number) => {
if (brightness <= 0.01) return;
const radius = config.glow * fly.scale * (0.55 + brightness * 0.6);
context.globalAlpha = brightness;
context.drawImage(sprite, fly.x - radius, fly.y - radius, radius * 2, radius * 2);
};
// Reduced motion: the flies held at points spread across the flash
// envelope, so some are bright, some are fading and some are dark.
// The uneven brightness is the rhythm, stated in one frame.
if (reduced) {
const still = () => {
context.clearRect(0, 0, width, height);
flies.forEach((fly, index) => {
const spread = index / Math.max(1, flies.length - 1);
draw(fly, spread < 0.55 ? Math.pow(1 - spread / 0.55, 1.4) : 0);
});
context.globalAlpha = 1;
};
still();
const onResizeStill = () => {
resize();
flies = Array.from({ length: count }, spawn);
still();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
context.clearRect(0, 0, width, height);
for (const fly of flies) {
// The random walk goes on the heading. On the position it would
// be noise; here it is a long, aimless curve.
fly.heading += (Math.random() - 0.5) * 1.8 * delta;
fly.x += Math.cos(fly.heading) * fly.speed * delta;
fly.y += Math.sin(fly.heading) * fly.speed * delta;
const margin = config.glow * 2;
if (fly.x < -margin) fly.x = width + margin;
if (fly.x > width + margin) fly.x = -margin;
if (fly.y < -margin) fly.y = height + margin;
if (fly.y > height + margin) fly.y = -margin;
draw(fly, brightnessAt(fly, elapsed));
}
context.globalAlpha = 1;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
for (const fly of flies) {
fly.x = Math.min(fly.x, width);
fly.y = Math.min(fly.y, height);
}
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
Dusk for a screen that should feel restful — a sleep mode, a wind-down state, a dark empty view. The flash envelope is asymmetric: up in about a tenth of a second, down over most of a second, then a long dark gap. A sine wave on opacity gives you dots that pulse, while the fast rise and slow decay says a light was switched on and is dying away, and the seconds of nothing in between are what make a flash an event rather than a rhythm. An unlit fly is not drawn at all. The drift is a random walk on each fly's heading rather than on its position — jittering a position reads as noise, while a wandering heading produces the long aimless curves that read as flight.