# Fireflies — Animated Background for React

> 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.

- Page: https://crucibleui.com/components/backgrounds/fireflies
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

```bash
npx shadcn@latest add @crucible/fireflies
```

Registry JSON: https://crucibleui.com/r/fireflies.json

Import from `@/components/crucible/fireflies`.

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
"use client";

import { Fireflies } from "@/components/crucible/fireflies";
import { DemoScene } from "@/components/registry/demo-scene";

export default function FirefliesDemo() {
  return (
    <DemoScene
      background={<Fireflies />}
      headline="Quiet magic, on by default"
      subhead="A field of warm glowing points drifting and breathing in the dark."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`fireflies.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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(",");

  // Speed-scaled clock, accumulated per frame so speed changes never jump the swarm.
  const phaseRef = React.useRef(0);
  const lastElapsedRef = React.useRef(0);
  const tick = React.useCallback(
    (elapsed: number) => {
      const it = internalsRef.current;
      let dt = elapsed - lastElapsedRef.current;
      lastElapsedRef.current = elapsed;
      if (dt > 0.1) dt = 0.1; // clamp tab-restore jumps
      if (dt < 0) dt = 0;
      phaseRef.current += dt * speedFactor;
      if (!it) return;
      tickFrame(it, phaseRef.current);
    },
    [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]);

  // Live-sync intensity without rebuilding the field.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.intensity = Math.max(intensity, 0);
    if (frozenRef.current) drawStatic(it);
  }, [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>
  );
}
```
