"use client";
import * as React from "react";
export interface FirefliesProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of fireflies. */
count?: number;
/** Glow colour (any CSS colour). Deepened to a darker amber-like tone on light themes. */
color?: string;
/** Drift speed multiplier. 0 holds them in place (they still blink). */
speed?: number;
/** Glow diameter of the nearest fireflies, in px. Far ones are about 40% of this. */
size?: number;
/** How strongly they drift toward the pointer, 0–1. 0 ignores the pointer. */
attraction?: number;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const mix = (a: RGB, b: RGB, t: number) => a.map((v, i) => Math.round(v + (b[i] - v) * t)) as RGB;
const rgba = ([r, g, b]: RGB, a: number) => `rgba(${r}, ${g}, ${b}, ${a})`;
/**
* Deepens a colour for light themes: a per-channel gamma keeps the dominant channel and drops the
* weaker ones (pale yellow → amber, not olive), then it is scaled down to a target luminance.
*/
function deepen(c: RGB, target: number): RGB {
const rich = c.map((v) => 255 * Math.pow(v / 255, 2.2)) as RGB;
const lum = luminance(rich);
const k = lum > target ? target / lum : 1;
return rich.map((v) => Math.round(v * k)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- Helpers --------------------------------- */
/** mulberry32: a tiny seeded PRNG, so the reduced-motion still is the same on every visit. */
function seeded(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Smooth 1D value noise in [-1, 1]: hashed lattice values joined by a smoothstep. */
function noise(x: number) {
const i = Math.floor(x);
const f = x - i;
const hash = (n: number) => {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return (s - Math.floor(s)) * 2 - 1;
};
const u = f * f * (3 - 2 * f);
return hash(i) + (hash(i + 1) - hash(i)) * u;
}
/* --------------------------------- Sprite ---------------------------------- */
const SPRITE = 96;
/**
* One radial-gradient glow, rendered once per colour and theme and then drawn scaled with
* drawImage — no per-frame blur. Dark themes get a hot pale core; light themes a dark amber
* body with a softer halo.
*/
function renderSprite(glow: RGB, core: RGB, dark: boolean) {
const canvas = document.createElement("canvas");
canvas.width = SPRITE;
canvas.height = SPRITE;
const ctx = canvas.getContext("2d");
if (!ctx) return canvas;
const c = SPRITE / 2;
const g = ctx.createRadialGradient(c, c, 0, c, c, c);
if (dark) {
g.addColorStop(0, rgba(core, 1));
g.addColorStop(0.07, rgba(core, 0.95));
g.addColorStop(0.14, rgba(glow, 0.85));
g.addColorStop(0.32, rgba(glow, 0.3));
g.addColorStop(0.6, rgba(glow, 0.08));
g.addColorStop(1, rgba(glow, 0));
} else {
g.addColorStop(0, rgba(core, 1));
g.addColorStop(0.08, rgba(core, 0.9));
g.addColorStop(0.16, rgba(glow, 0.62));
g.addColorStop(0.36, rgba(glow, 0.22));
g.addColorStop(0.65, rgba(glow, 0.05));
g.addColorStop(1, rgba(glow, 0));
}
ctx.fillStyle = g;
ctx.fillRect(0, 0, SPRITE, SPRITE);
return canvas;
}
/* -------------------------------- Component -------------------------------- */
const STEP_MS = 1000 / 60;
const PULL_RADIUS = 260;
interface Fly {
x: number;
y: number;
vx: number;
vy: number;
/** 0 = far, 1 = near. */
depth: number;
/** Offset into the steering noise. */
seed: number;
/** Blink rhythm: period (s), phase (0–1) and the lit share of each cycle. */
period: number;
phase: number;
lit: number;
}
/**
* Fireflies drifting on smooth noise paths and blinking on their own rhythms. Depth sets size,
* brightness and speed; a mouse or pen pointer gently draws them in (they circle rather than
* pile up). Glows are pre-rendered sprites and the canvas pauses offscreen; reduced motion
* shows a still scene.
*/
export function Fireflies({
count = 36,
color = "#FFC65C",
speed = 1,
size = 26,
attraction = 0.5,
className = "",
children,
...props
}: FirefliesProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const total = Math.max(1, Math.round(count));
const glowSize = Math.max(6, size);
const pull = Math.min(1, Math.max(0, attraction));
const rnd = reduceMotion ? seeded(1729 + total) : Math.random;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let pending = 0;
let time = rnd() * 100; // seconds, drives blinking and steering noise
let inView = false;
let dark = true;
let sprite: HTMLCanvasElement | null = null;
const ptr = { x: 0, y: 0, inside: false, touch: false };
// Sorted far → near so nearer fireflies draw on top.
const flies: Fly[] = Array.from({ length: total }, () => ({
x: 0,
y: 0,
vx: 0,
vy: 0,
depth: rnd(),
seed: rnd() * 1000,
period: 2.4 + rnd() * 4.2,
phase: rnd(),
lit: 0.28 + rnd() * 0.2,
})).sort((a, b) => a.depth - b.depth);
const buildSprite = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
dark = luminance(ink) > 0.5;
const base = resolveColor(color, [255, 198, 92]);
sprite = dark
? renderSprite(base, mix(base, [255, 255, 255], 0.65), true)
: renderSprite(deepen(base, 0.48), deepen(base, 0.32), false);
};
/** Soft blink: a smooth rise and fall during the lit share of the cycle, a faint ember otherwise. */
const glowOf = (f: Fly) => {
// The reduced-motion still shows a designed mix of bright and dim fireflies instead of a
// random moment in which most of them happen to be dark.
if (reduceMotion) return 0.3 + 0.7 * ((f.phase * 7.31) % 1);
const p = (time / f.period + f.phase) % 1;
const on = p < f.lit ? Math.sin((Math.PI * p) / f.lit) : 0;
return 0.1 + 0.9 * on * on;
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (!sprite) return;
ctx.globalCompositeOperation = dark ? "lighter" : "source-over";
for (const f of flies) {
const s = glowSize * (0.4 + 0.6 * f.depth) * 2.2; // the sprite's visible glow is ~45% of its box
ctx.globalAlpha = glowOf(f) * (0.45 + 0.55 * f.depth);
ctx.drawImage(sprite, f.x - s / 2, f.y - s / 2, s, s);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
};
/** One fixed 60Hz step of steering, pointer pull and wrapping. */
const step = () => {
time += STEP_MS / 1000;
const margin = glowSize;
for (const f of flies) {
const pace = (0.25 + 0.45 * f.depth) * speed;
// Heading wanders along smooth noise; each firefly reads its own stretch of it.
const heading = f.seed + noise(f.seed + time * 0.18) * Math.PI * 1.6;
let ax = (Math.cos(heading) * pace - f.vx) * 0.03;
let ay = (Math.sin(heading) * pace * 0.7 - f.vy) * 0.03;
if (ptr.inside && pull > 0 && speed > 0) {
const dx = ptr.x - f.x;
const dy = ptr.y - f.y;
const d = Math.hypot(dx, dy) || 1;
if (d < PULL_RADIUS) {
const k = (1 - d / PULL_RADIUS) * pull * 0.05 * (0.5 + f.depth * 0.5);
const toward = d > 34 ? 1 : -0.6; // keep a little room around the pointer
// Toward the pointer, plus a sideways swirl so they circle instead of clumping.
ax += (dx / d) * k * toward - (dy / d) * k * 0.7;
ay += (dy / d) * k * toward + (dx / d) * k * 0.7;
}
}
f.vx = (f.vx + ax) * 0.985;
f.vy = (f.vy + ay) * 0.985;
f.x += f.vx;
f.y += f.vy;
if (f.x < -margin) f.x = width + margin;
else if (f.x > width + margin) f.x = -margin;
if (f.y < -margin) f.y = height + margin;
else if (f.y > height + margin) f.y = -margin;
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : STEP_MS);
last = now;
// Fixed 60Hz steps keep drift and pull identical on 60, 120 or 144Hz displays; a short frame
// may run no step, and simply redraws the same state. The scene never settles (it blinks).
pending = Math.min(pending + dt, STEP_MS * 4);
let stepped = false;
while (pending >= STEP_MS) {
step();
pending -= STEP_MS;
stepped = true;
}
if (stepped) draw();
schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
const first = width === 0;
const sx = width ? rect.width / width : 1;
const sy = height ? rect.height / height : 1;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
for (const f of flies) {
if (first) {
f.x = rnd() * width;
f.y = rnd() * height;
} else {
f.x *= sx;
f.y *= sy;
}
}
draw();
schedule();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
};
// Touch has no hover: they gather toward a finger only while it is pressed.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = true;
ptr.inside = locate(e);
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
};
const onPointerLeave = () => (ptr.inside = false);
const onVisibility = () => (document.hidden ? stop() : schedule());
const onThemeChange = () => {
buildSprite();
if (!raf) draw();
};
// Pointer listeners only exist while the canvas is on screen.
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
const listenPointer = pull > 0 && !reduceMotion;
let listening = false;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (listenPointer && inView !== listening) {
listening = inView;
listen(inView);
}
if (inView) schedule();
else stop();
});
buildSprite();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
if (listening) listen(false);
};
}, [count, color, speed, size, attraction, reduceMotion]);
return (
<div ref={rootRef} className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}