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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/fireflies.jsonPrompt
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 with1 - 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 sameseed. - 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 orcolor-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
drawImageper firefly. Rebuilding a radial gradient per firefly per frame would burn main-thread time linear in the count, andctx.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
activecounter 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: reducethe 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 thespeed={0}frame.
Snow
Canvas snowfall with per-flake sway, a depth ramp from soft foreground bokeh to crisp distant specks, and an optional drift that builds along the bottom edge and settles.
Constellation
A drifting node network on one canvas — every pair closer than the link threshold is stroked, opacity falling off with distance, with optional pointer attraction.