Fireflies
A calm, magical night: warm glowing points drift along gentle seeded wandering paths, each softly breathing inside an additive-glow halo and trailing a short fading wake. Near fireflies big/bright/roaming, far ones dim pinpricks — real depth parallax. 2D canvas with additive blending, seeded PRNG for a deterministic SSR-safe layout, pauses offscreen, honors prefers-reduced-motion with a still field of glowing points.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface FirefliesProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* How many fireflies drift in the field. Clamped 8–160. Near ones are large,
* bright and roam widely; far ones are tiny, dim pinpricks — real depth.
* @default 46
*/
count?: number;
/**
* Glow palette, hottest → coolest, mapped across the depth field (nearest
* fireflies take the first color). Defaults to a warm bioluminescent glow:
* soft gold melting into a gentle leaf-green. Pass your own for a different
* night — cool `["#dbeafe", "#a5f3fc", "#818cf8"]` reads as winter starlight.
* @default ["#fff1c1", "#ffd166", "#b5e48c"]
*/
colors?: string[];
/** Drift / breathing-speed multiplier. 1 = default, 2 = twice as lively. @default 1 */
speed?: number;
/** Overall glow brightness and bloom, 0–2. @default 1 */
intensity?: number;
/** Deterministic seed for the wander layout — same seed, same night. @default 1 */
seed?: number;
/** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
dpr?: number;
/** Freeze the field at a static, aesthetic frame of glowing points. @default false */
paused?: boolean;
}
const DEFAULT_COLORS = ["#fff1c1", "#ffd166", "#b5e48c"];
// Deep midnight bed — cool blue-black so the warm fireflies read as light.
const BG: [number, number, number] = [7, 9, 16];
// Per-frame fade toward the bed: leaves each firefly a short, soft trail.
const TRAIL_FADE = 0.14;
/** Deterministic mulberry32 PRNG — same seed always produces the same field. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Normalize any CSS color to [r,g,b] via a throwaway 1×1 draw. */
function colorToRgb(ctx: CanvasRenderingContext2D, color: string): [number, number, number] {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
}
/**
* Pre-render one soft radial-glow sprite per palette color: a near-white hot
* core fading through the color into a transparent halo. Built once, then
* blitted additively per firefly so overlaps bloom — no per-frame gradients.
*/
function makeSprite(rgb: [number, number, number], scale: number): HTMLCanvasElement {
const D = Math.max(24, Math.round(80 * scale));
const c = document.createElement("canvas");
c.width = D;
c.height = D;
const g = c.getContext("2d")!;
const cx = D / 2;
const [r, gg, b] = rgb;
// Hot core mixes heavily toward white so each point reads incandescent.
const cr = Math.round(r * 0.3 + 255 * 0.7);
const cg = Math.round(gg * 0.3 + 255 * 0.7);
const cb = Math.round(b * 0.3 + 255 * 0.7);
const grad = g.createRadialGradient(cx, cx, 0, cx, cx, cx);
grad.addColorStop(0, `rgba(${cr},${cg},${cb},1)`);
grad.addColorStop(0.12, `rgba(${r},${gg},${b},0.9)`);
grad.addColorStop(0.34, `rgba(${r},${gg},${b},0.28)`);
grad.addColorStop(1, `rgba(${r},${gg},${b},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, D, D);
return c;
}
interface Firefly {
/** Home anchor in device px — the wander orbits this point. */
hx: number;
hy: number;
/** Depth 0 (far) → 1 (near): drives size, brightness, wander amplitude. */
z: number;
/** Glow sprite draw size, device px. */
size: number;
spriteIndex: number;
brightness: number;
// Two-frequency Lissajous wander per axis → organic, non-obviously-looping.
ampX: number;
ampY: number;
fx1: number;
fx2: number;
fy1: number;
fy2: number;
phx1: number;
phx2: number;
phy1: number;
phy2: number;
// Breathing pulse.
pulseFreq: number;
pulsePhase: number;
}
function makeFirefly(
rand: () => number,
w: number,
h: number,
scale: number,
paletteLen: number
): Firefly {
// Skew depth toward "far" so a few near fireflies float over a dim deep field.
const z = rand() * rand();
const size = (11 + z * 34) * scale;
return {
hx: rand() * w,
hy: rand() * h,
z,
size,
spriteIndex: Math.min(paletteLen - 1, Math.floor((1 - z) * paletteLen)),
brightness: 0.32 + z * 0.55,
ampX: (10 + z * 46) * scale,
ampY: (8 + z * 38) * scale,
fx1: 0.12 + rand() * 0.22,
fx2: 0.05 + rand() * 0.12,
fy1: 0.1 + rand() * 0.2,
fy2: 0.04 + rand() * 0.11,
phx1: rand() * Math.PI * 2,
phx2: rand() * Math.PI * 2,
phy1: rand() * Math.PI * 2,
phy2: rand() * Math.PI * 2,
pulseFreq: 0.5 + rand() * 1.6,
pulsePhase: rand() * Math.PI * 2,
};
}
function buildField(
count: number,
seed: number,
w: number,
h: number,
scale: number,
paletteLen: number
): Firefly[] {
const n = Math.min(160, Math.max(8, Math.round(count)));
const rand = mulberry32(seed);
const out: Firefly[] = [];
for (let i = 0; i < n; i++) out.push(makeFirefly(rand, w, h, scale, paletteLen));
return out;
}
interface Internals {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
scale: number;
sprites: HTMLCanvasElement[];
flies: Firefly[];
intensity: number;
}
/** Position of a firefly at effective time `t` (seconds, speed already applied). */
function posX(f: Firefly, t: number): number {
return f.hx + f.ampX * (Math.sin(t * f.fx1 + f.phx1) * 0.7 + Math.sin(t * f.fx2 + f.phx2) * 0.3);
}
function posY(f: Firefly, t: number): number {
return f.hy + f.ampY * (Math.sin(t * f.fy1 + f.phy1) * 0.7 + Math.sin(t * f.fy2 + f.phy2) * 0.3);
}
/** Draw one firefly's additive glow. `pulse` in [0,1] scales alpha (breathing). */
function drawFly(it: Internals, f: Firefly, x: number, y: number, pulse: number) {
const alpha = Math.max(0, Math.min(1, f.brightness * pulse * it.intensity));
if (alpha <= 0.004) return;
const { ctx } = it;
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = alpha;
const s = f.size;
ctx.drawImage(it.sprites[f.spriteIndex], x - s / 2, y - s / 2, s, s);
}
function paintBackground(it: Internals) {
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
it.ctx.fillStyle = `rgb(${BG[0]},${BG[1]},${BG[2]})`;
it.ctx.fillRect(0, 0, it.w, it.h);
}
/** One live frame: fade toward the bed (soft trails), then wander + breathe. */
function tickFrame(it: Internals, t: number) {
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
it.ctx.fillStyle = `rgba(${BG[0]},${BG[1]},${BG[2]},${TRAIL_FADE})`;
it.ctx.fillRect(0, 0, it.w, it.h);
for (const f of it.flies) {
const pulse = 0.42 + 0.58 * (0.5 + 0.5 * Math.sin(t * f.pulseFreq + f.pulsePhase));
drawFly(it, f, posX(f, t), posY(f, t), pulse);
}
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
}
/** Static frame for paused / reduced-motion: a still field of glowing points. */
function drawStatic(it: Internals) {
paintBackground(it);
for (const f of it.flies) {
// Vary brightness by phase so the frozen field reads as scattered lights,
// not a uniform grid — never blank, never flat.
const pulse = 0.55 + 0.45 * (0.5 + 0.5 * Math.sin(f.pulsePhase));
drawFly(it, f, posX(f, 0), posY(f, 0), pulse);
}
it.ctx.globalCompositeOperation = "source-over";
it.ctx.globalAlpha = 1;
}
/**
* Fireflies — a calm, magical night. Warm glowing points drift along gentle,
* seeded wandering paths in the dark, each softly breathing (pulsing brightness)
* inside an additive-glow halo and trailing a short, fading wake. Near fireflies
* are large, bright and roam widely; far ones are dim pinpricks — a real depth
* parallax, not a uniform sprinkle. 2D canvas with additive blending; seeded
* PRNG for a deterministic, SSR-safe layout, pauses offscreen and on hidden
* tabs, and honors prefers-reduced-motion with a still field of glowing points.
* Drop it inside any `relative` container.
*/
export function Fireflies({
count = 46,
colors = DEFAULT_COLORS,
speed = 1,
intensity = 1,
seed = 1,
dpr = 2,
paused = false,
className,
style,
...props
}: FirefliesProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const internalsRef = React.useRef<Internals | null>(null);
const reducedMotion = useReducedMotion();
const frozen = paused || reducedMotion;
const frozenRef = React.useRef(frozen);
frozenRef.current = frozen;
const speedFactor = Math.max(speed, 0);
const colorsKey = colors.join(",");
const tick = React.useCallback(
(elapsed: number) => {
const it = internalsRef.current;
if (!it) return;
tickFrame(it, elapsed * speedFactor);
},
[speedFactor]
);
const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: frozen });
// Set up canvas, sprites and the field; rebuild on size / palette / seed / count.
React.useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const scale = Math.min(
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
Math.min(Math.max(dpr, 1), 2)
);
const palette = colors.length ? colors : DEFAULT_COLORS;
const sprites = palette.map((c) => makeSprite(colorToRgb(ctx, c), scale));
const onContextLost = (e: Event) => e.preventDefault();
canvas.addEventListener("contextlost", onContextLost);
const build = () => {
const w = Math.max(1, Math.round(container.clientWidth * scale));
const h = Math.max(1, Math.round(container.clientHeight * scale));
canvas.width = w;
canvas.height = h;
canvas.style.width = "100%";
canvas.style.height = "100%";
const it: Internals = {
ctx,
w,
h,
scale,
sprites,
flies: buildField(count, seed, w, h, scale, sprites.length),
intensity: Math.max(intensity, 0),
};
internalsRef.current = it;
// Paint one frame immediately so mount / resize never flashes empty.
if (frozenRef.current) drawStatic(it);
else {
paintBackground(it);
for (const f of it.flies) {
const pulse = 0.42 + 0.58 * (0.5 + 0.5 * Math.sin(f.pulsePhase));
drawFly(it, f, posX(f, 0), posY(f, 0), pulse);
}
}
};
const ro = new ResizeObserver(build);
ro.observe(container);
build();
return () => {
ro.disconnect();
canvas.removeEventListener("contextlost", onContextLost);
internalsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, seed, colorsKey, dpr, intensity]);
// Re-render the static frame in place when paused / reduced-motion toggles on.
React.useEffect(() => {
const it = internalsRef.current;
if (!it || !frozen) return;
drawStatic(it);
}, [frozen]);
return (
<div
ref={containerRef}
aria-hidden
data-crucible="fireflies"
className={cn("absolute inset-0 -z-10 overflow-hidden", className)}
style={style}
{...props}
>
<canvas ref={canvasRef} className="block h-full w-full" />
{/* Soft vignette so the swarm sinks into the frame edges. */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(120% 120% at 50% 45%, transparent 52%, rgba(0,0,0,0.55) 100%)",
}}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/firefliesProps
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 46 | How many fireflies drift in the field. Clamped 8–160. Near ones are large, bright and roam widely; far ones are tiny, dim pinpricks — real depth. |
| colors | string[] | ["#fff1c1", "#ffd166", "#b5e48c"] | Glow palette, hottest → coolest, mapped across the depth field (nearest fireflies take the first color). Defaults to a warm bioluminescent glow: soft gold melting into a gentle leaf-green. Pass your own for a different night — cool ["#dbeafe", "#a5f3fc", "#818cf8"] reads as winter starlight. |
| speed | number | 1 | Drift / breathing-speed multiplier. 1 = default, 2 = twice as lively. |
| intensity | number | 1 | Overall glow brightness and bloom, 0–2. |
| seed | number | 1 | Deterministic seed for the wander layout — same seed, same night. |
| dpr | number | 2 | devicePixelRatio ceiling. Clamped 1–2. |
| paused | boolean | false | Freeze the field at a static, aesthetic frame of glowing points. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.