Backgrounds

Fireflies

Wandering points of light on one canvas — each walks its own eased random path and flashes on its own period, with the glow blitted from a sprite baked once.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the glow is painted with. */
export type FirefliesTone =
  | "chart-1"
  | "chart-2"
  | "chart-3"
  | "chart-4"
  | "chart-5"
  | "primary"
  | "foreground"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/fireflies.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Fireflies" component — wandering points
of light painted on one canvas, used as a night-time hero / empty-state / card
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
No animation library, no particle library.

Contract
- export function Fireflies(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - count?: number (default 26) — requested fireflies, clamped to 0..120 and
    then capped again by area (see density).
  - density?: number (default 9000, floored at 300) — CSS px squared of canvas
    required per firefly. Lower is denser; this is the cap that stops a small
    card from being carpeted.
  - size?: [number, number] (default [1, 2.4]) — core radius range in CSS px,
    normalised if reversed and clamped to 0.4..12.
  - glow?: number (default 7) — glow radius as a MULTIPLE of the core radius,
    clamped to 1..24. It changes the baked sprite, not the per-frame cost.
  - speed?: number (default 1) — multiplier on the whole simulation (drift,
    turns, flashing). 0 freezes it AND the rAF loop never starts.
  - wander?: number (default 1, clamped 0..4) — how far one turn may swing the
    heading. 0 flies dead straight forever, 4 is restless.
  - pulse?: number (default 0.75, clamped 0..1) — depth of the flash. 0 is a
    field of steady lamps; 1 goes fully dark between flashes.
  - tone?: "chart-1".."chart-5" | "primary" | "foreground" | "muted"
    (default "chart-3") — which semantic token the light is painted with.
  - seed?: number (default 12) — integer seed. Same seed, same swarm.
  - children render above the canvas; className merges onto the root.
- "use client": the component owns a canvas, rAF and three observers.
- There is no pointer API and no keyboard API on purpose: this is ambient
  decoration, not an interactive field. If you want the cursor to attract or
  scatter the lights, that is a different component.
- Clamp every numeric prop before use and treat non-finite values as the
  default. A NaN count empties the field, density 0 asks for an infinite one, a
  negative speed walks the swarm backwards, a reversed size tuple hands Math a
  negative range, and glow 0 would ask for a zero-radius sprite.

Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
  aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
  size-full matters, an absolutely positioned replaced element with inset-0
  alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
  children. The canvas has no tabindex, so the decoration adds no Tab stop, and
  the component paints NO background of its own: the surface belongs to the
  consumer.
- THE GLOW IS A BAKED SPRITE, AND THE RAMP IS BUILT BY ALPHA ACCUMULATION.
  Rasterise one sprite per (ink, radius) into an offscreen canvas and draw every
  firefly as a scaled drawImage of it. Do NOT build a radial gradient per
  firefly per frame, and never ctx.filter = "blur()".
  Building the ramp without a colour literal: fill concentric discs from the rim
  inward, each with the alpha that lifts the running coverage onto the target
  profile — a = (target - covered) / (1 - covered), which is the inverse of
  source-over — using the raw token string as the only fill. That is the whole
  trick: a gradient stop needs a colour that CARRIES alpha, and the only ways to
  get one are a hard-coded mask colour behind destination-in, or color-mix()
  support. Painting the token over itself needs neither, and the cost lands once
  per (ink, radius) instead of once per frame. Use ~24..96 shells scaled by
  radius so the step stays sub-pixel and the ramp does not band.
  Alpha profile at normalised radius u, with core = 1 / glow:
    u <= core  ->  1 - 0.5 * smoothstep(u / core)      (a near-solid core)
    u >  core  ->  0.5 * (1 - (u - core) / (1 - core))^2.6   (soft falloff to 0)
  Because every firefly shares the same core/glow ratio, ONE sprite serves the
  whole field; each one just blits it at its own radius.
- Guard the ink: set fillStyle = "transparent", READ IT BACK and keep that
  string, then assign the token. An unparsable value leaves fillStyle untouched,
  so comparing against the captured sentinel detects it — and capturing the
  engine's own normalisation beats hard-coding one browser's spelling of
  transparent. On failure paint nothing rather than shipping opaque black.
- Deterministic swarm: every position, heading, cruise speed, core radius, peak
  alpha, flash period, phase and duty is an integer hash of (id, salt), where id
  mixes the seed with the firefly's index. Address them by index instead of
  pulling from a PRNG stream, so growing the field after a resize never
  reshuffles what is already on screen. Math.random() is never called — not in
  render (purity / SSR) and not in the loop (screenshots must be reproducible).
- MOTION IS AN EASED RANDOM WALK, NOT A SINE. Each firefly carries a heading, a
  target heading and the simulated time of its next turn. When t passes turnAt:
  bump a per-firefly turn counter, take salt = 400 + turns * 2, draw a new
  target = heading + (hash(id, salt) - 0.5) * 2 * 1.1 * wander radians, and
  schedule the next turn 0.9..2.6s out from hash(id, salt + 1). Interleaving the
  two salts keeps the swing and the interval from ever drawing the same number
  as each other on a later turn. Every frame the heading eases toward
  the target with 1 - exp(-ds * 1.6) — framerate-independent, and a ~0.6s arc
  rather than a snap. Then advance along the heading at the firefly's own
  5..17 px/s. Salting the hash with the turn counter is what keeps an endless
  walk unpredictable AND reproducible. Never normalise the heading: the walk
  only ever adds small deltas, so there is no wrap discontinuity to ease across.
  Seed the first turnAt from a hash too, or the whole swarm pivots in unison at
  t = 0.
- FLASHING IS PER-PERIOD, NOT PER-PHASE. phase = (t / period + offset) mod 1,
  with period 2.4..6.2s, offset and duty (0.28..0.55 of the period) all
  per-firefly. Outside the duty window the flash term is 0; inside it, run
  u = phase / duty through a fast rise / slow fade triangle (rise = 0.3 of the
  window) and smoothstep it. Brightness = peakAlpha * (1 - pulse + pulse *
  flash). Equal periods with staggered phases still read as one metronome once
  the eye locks onto the interval; distinct periods are what make the field
  never strobe together.
- Edges: wrap toroidally with a margin of the FULL glow radius, so a firefly
  never pops in or out at an edge (its alpha is already 0 out there).
- OBJECT POOL: the swarm array only ever grows, to the largest count this
  instance has needed; a separate `active` counter says how many are simulated
  and drawn. A container that shrinks parks the tail instead of dropping it, so
  the fireflies come back exactly as they were, and no frame ever allocates.
  Effective count = min(count, floor(area / density)), with the 120 ceiling
  applied to the prop up front.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
  padding would offset the box); its first callback is the initial sizing. Try
  observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
  browsers that do not know that box throw a WebIDL TypeError from observe()
  rather than ignoring it — and fall back to observe(canvas). Take the MAX of
  window.devicePixelRatio and (deviceBoxWidth / cssWidth), clamped to 1..2:
  neither source is trustworthy alone, emulated surfaces report the device box
  in CSS px while rendering at 2x, and hi-dpi windows have been measured
  reporting devicePixelRatio 1 with a truthful box. Re-apply ctx.setTransform
  after every resize (writing canvas.width resets the context) and derive the
  scale from the real backing size. Bail out early when the CSS box and the dpr
  are both unchanged; otherwise rescale existing fireflies in place — including
  the parked tail — instead of reseeding them.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
  on screen, document.visibilityState is "visible", motion is allowed and
  speed > 0. dt is clamped to 1/30s so a backgrounded tab cannot teleport the
  swarm on resume, and the time base resets whenever the loop restarts.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
  getComputedStyle(canvas).color; rebake the sprite only when the resolved
  string actually changed, and repaint the still frame when the loop is paused.
  No settle delay is needed — the ink is a custom property behind `color` and
  custom properties are not animatable, unlike a transition-colors surface.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
  snapshot false, so it is hydration-safe) and keep it in the effect deps. Under
  reduce the loop never starts: exactly one frame is painted, and because every
  flash phase is already spread apart at t = 0 that frame is a full swarm at
  mixed brightness, not a blank box. It is the same raster speed = 0 produces.
- Cleanup on unmount: cancelAnimationFrame, all three observers, the
  visibilitychange listener, and set the sprite canvas to 0x0 — a detached
  canvas keeps its backing store. Cancel the loop BEFORE freeing the sprite: a
  leaked loop that reaches a 0x0 sprite throws InvalidStateError from drawImage
  on every frame.

Rendering & styling
- Semantic tokens only, and zero colour literals anywhere in the file — not even
  an alpha mask, because the ramp is compositing, not a gradient. The ink is
  var(--chart-1..5) / var(--primary) / var(--foreground) /
  var(--muted-foreground), written onto the canvas as an inline `color` and read
  back through getComputedStyle, so any syntax the browser resolves (oklch(),
  color-mix(), a brand colour parked behind the token) works and light/dark
  comes for free. Per-firefly brightness rides on globalAlpha.
- Merge the consumer className via cn() on the root; the canvas keeps its own
  classes. Height, radius, border and surface all come from the call site.
- Accessibility: the canvas is aria-hidden, pointer-events-none and has no
  tabindex, so the effect never enters the accessibility tree, never eats a
  click and never takes a Tab stop; children stay fully interactive above it.
  There is no state a screen reader needs to hear — nothing here is a status.
- Honest cost: one drawImage per effective firefly per frame, plus one sprite
  bake per (ink, radius). It is linear in the effective count and independent of
  glow, size and pulse. `density` is the guard that keeps a phone-sized card
  cheap; the 120 ceiling is the guard against a typo.

Customization levers
- Character of the walk: `wander` is the headline dial (0 = straight glides,
  1 = default meander, 3+ = restless). Behind it, TURN_SWING (1.1 rad),
  TURN_EASE (1.6/s) and the TURN_MIN/TURN_MAX interval are the three constants
  that decide how sharp, how smooth and how often the turns are. Widen
  MIN_DRIFT/MAX_DRIFT (5..17 px/s) for a faster swarm.
- Flash feel: `pulse` sets the depth; MIN_PERIOD/MAX_PERIOD widen or narrow the
  spread of rhythms (narrow it and the field starts to look synchronised),
  MIN_DUTY/MAX_DUTY set how much of each cycle is lit, and RISE (0.3) is the
  rise/fade asymmetry — lower it for a sharper strobe, raise it toward 0.5 for a
  symmetric breath.
- Glow shape: `glow` and `size` together decide the look — a small core with
  glow 14+ reads as a halo, glow 2..3 as a hard dot. CORE_KEEP (0.5) and
  HALO_FALLOFF (2.6) reshape the profile itself: raise the exponent for a
  tighter, more contained light.
- Density and cost: `count` is the ceiling you ask for, `density` is the px² the
  container must afford per firefly. Lower density to let a big count land;
  raise it to keep small cards sparse. MAX_FIREFLIES (120) and MAX_DPR (2) are
  the safety valves.
- Palette: `tone` picks the token. Add an entry pointing anywhere —
  var(--primary-foreground) is the right ink on an inverted panel,
  var(--destructive) turns the swarm into embers.
- Surface: the component paints nothing behind itself. It reads best on a dark
  or dim panel; on a light surface the same field is warm dust rather than a
  glow, which is a property of light, not a bug to tune away.

Concepts

  • Eased random walk — direction is an event, not a per-frame nudge: every 0.9–2.6s a firefly draws a new target heading up to ±1.1 rad away (scaled by wander) and then eases onto it with 1 - exp(-ds · 1.6), a ~0.6s arc. The hash is salted with the firefly's own turn counter, so an endless walk stays unpredictable and still repaints identically from the same seed.
  • Per-firefly flash period — each light has its own period (2.4–6.2s), its own offset and its own duty cycle. Staggering phases alone is not enough: with one shared period the eye eventually locks onto the interval and the field reads as a metronome. Distinct periods are what make a swarm look alive.
  • Alpha accumulation instead of a gradient — the glow ramp is painted by filling concentric discs of the raw token colour from the rim inward, each at a = (target - covered) / (1 - covered), the inverse of source-over. A gradient stop would need a colour that carries alpha, which means either a hard-coded mask colour or color-mix() support; compositing the token over itself needs neither, so the file contains no colour literal at all.
  • Cached sprite blit — that ramp is baked once per (ink, radius) into an offscreen canvas, and every frame afterwards is one drawImage per firefly. Rebuilding a radial gradient per firefly per frame would burn main-thread time linear in the count, and ctx.filter = "blur()" would re-run a gaussian per firefly per frame; the bake happens at mount and on a theme flip, nowhere else.
  • Object pool with a parked tail — the swarm array only grows, to the largest count this instance ever needed, while an active counter decides how many are simulated. Shrinking the container parks fireflies instead of dropping them, so no frame allocates and a field that grows back is exactly the field that left.
  • Area-capped density — the effective count is min(count, floor(area / density)) under a hard 120 ceiling, so the same JSX is a full swarm in a hero and a handful of lights in a 320px card, without the caller measuring anything.
  • Reduced-motion still frame — under prefers-reduced-motion: reduce the loop never starts, but the single painted frame is the full swarm with its flash phases already spread apart, so it still reads as fireflies rather than a blank box. It is bit-identical to the speed={0} frame.

On This Page