Particles
An interactive canvas particle field — dots, linked constellations, snow or fireflies, painted in your text token and scattered by the pointer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/particles.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Particles" component — an interactive
canvas particle field used as a section background. Its only dependency is a
cn() class merger (clsx + tailwind-merge). No particle library.
Contract
- export function Particles(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- count?: number (default 60) — requested particle count; the effective
count is min(count, floor(area / density)).
- variant?: "dots" | "links" | "snow" | "fireflies" (default "dots").
- speed?: number (default 1) — multiplier on the whole simulation
(drift, sway, twinkle); 0 freezes the field.
- size?: [min, max] (default [1, 2.6]) — particle radius range in CSS px.
- interactive?: boolean (default true) — pointer pushes particles away,
and pulls them in for "fireflies".
- interactionRadius?: number (default 120) — pointer influence disc, CSS px.
- color?: string (default "currentColor") — any CSS color; when it is not
"currentColor" it is written as an inline color style on the canvas.
- density?: number (default 2400) — CSS px of canvas required per
particle; this is the area cap, lower means denser.
- children render above the canvas; className merges onto the root.
- "use client": the component owns a canvas, rAF, observers and pointer
events, so it cannot be a server component.
- Clamp every numeric prop before use: count >= 0 and integral, density >=
100 (0 would ask for an infinite field), speed >= 0, interactionRadius >=
0, and normalise a reversed size tuple with min/max.
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 would render at its intrinsic 300x150 — and (b) a "relative z-10"
wrapper for children, so content always sits above the field and the
canvas can never intercept a click.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box). Its first callback is the initial sizing.
On every callback set canvas.width/height to cssSize * dpr with dpr =
min(devicePixelRatio, 2) — capping at 2 keeps 3x phones from quadrupling
fill cost for no visible gain on 1-3px discs — then re-apply
ctx.setTransform(dpr, 0, 0, dpr, 0, 0), because resizing the backing store
resets the context. Everything else draws in CSS px. Existing particles
are rescaled by (newW/oldW, newH/oldH) instead of being reseeded, so a
resize never reshuffles the field.
- Deterministic scatter: positions, velocities, radii, phases and alphas all
come from a seeded 32-bit LCG (state = (state * 1664525 + 1013904223) >>> 0,
divided by 2^32) created once per effect run from a fixed constant seed.
Never call Math.random() — not during render (it breaks render purity and
SSR) and not in the loop (the field must be reproducible for screenshots).
Growing the field after a resize continues the same LCG stream.
- Simulation, per frame: dt = clamp((now - last) / 1000, max 1/30) so a
backgrounded tab cannot teleport the field on resume; ds = dt * speed
accumulates into a simulated clock t used for every sine phase.
- dots / links: straight drift, x += vx * ds.
- snow: y += vy * ds downward, x += (vx + sin(t * 0.6 + phase) * 14) * ds,
each flake swaying on its own phase.
- fireflies: heading += sin(t * 0.7 + phase) * 1.5 * ds, then move along
that heading — a smooth deterministic wander, no random walk.
- All variants wrap toroidally at the edges with a radius-sized margin.
- Pointer force: track pointermove on the root, converting client coords with
the canvas's own bounding rect, and store them in a ref (never state — a
fast pointer stream must not re-render the subtree or its children).
pointerleave clears the active flag. Each frame, particles inside
interactionRadius get a positional offset along the away-vector with linear
falloff to the rim; the offset uses real dt, not ds, so slowing the field
down never makes the cursor feel unresponsive. Displacement is applied to
position, not velocity, so the field relaxes back to its normal drift when
the pointer leaves instead of accumulating energy. Fireflies use a negative
force (attraction) whose strength additionally fades out inside the last
24px, so they orbit the cursor instead of piling into one dot.
- links variant: neighbour queries go through a uniform spatial grid whose
cell size equals the link radius. Bucket every particle by cell each frame,
then for each particle scan the tail of its own bucket plus 4 of the 8
neighbouring cells (E, SW, S, SE) — that visits every unordered pair
exactly once. Cost is O(n*k) with k the mean neighbourhood occupancy
(~20 at the default spacing) instead of O(n^2), which is what keeps 200+
particles at 60fps. The link radius is derived from the field's own
spacing, clamp(sqrt(area / n) * 1.6, 48, 150), so sparse and dense fields
both read as a network. Line alpha fades linearly with distance.
- Colour: read getComputedStyle(canvas).color once per sizing pass and hand
the string straight to ctx.fillStyle / ctx.strokeStyle; per-particle alpha
rides on ctx.globalAlpha. Never hand-parse RGB — passing the computed
string through means any colour syntax the browser understands works
(oklch(), rgb(), a var() that already resolved). Because color defaults to
"currentColor" and the canvas inherits it, the field follows whatever text
token the container carries. Re-read on: resize, resume from hidden/
offscreen, and a MutationObserver watching class/style/data-theme on
<html> (that is how theme switchers flip); when the loop is paused the
observer also repaints the still frame so it never keeps the old theme's
colour.
- Power: the rAF loop only runs when all three hold — an IntersectionObserver
says the canvas is on screen, document.visibilityState is "visible", and
reduced motion is off. Both observers and the visibilitychange listener
start/stop the loop; on resume the time base resets so no giant dt lands.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (subscribe
to the media query, server snapshot false, so it is hydration-safe) and
keep it in the effect deps. Under reduce the loop never starts: the
component paints exactly one static frame (still a full, good-looking
scatter, still theme-coloured) and the pointer handler returns early, so
the field does not react to the cursor at all.
- Cleanup on unmount: cancelAnimationFrame, ResizeObserver.disconnect,
IntersectionObserver.disconnect, MutationObserver.disconnect, and remove
the visibilitychange listener.
Rendering & styling
- Semantic tokens only, and in fact zero colour literals anywhere: the ink is
whatever currentColor resolves to (bg-card / text-primary /
text-muted-foreground on the container all just work), so dark mode is
free. Alpha lives in globalAlpha, not in the colour string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none, so it is
pure decoration and children stay fully interactive and selectable.
Customization levers
- Variant tuning: BASE_SPEED and POINTER_FORCE are per-variant maps — change
one entry to retune a single field without touching the others. Flip a
POINTER_FORCE sign to turn repulsion into attraction.
- Density feel: pair count with density. count is the ceiling you ask for,
density is the px per particle the container must afford; lower density to
let a big count actually land, raise it to keep small cards sparse.
- Link look: LINK_ALPHA, the clamp bounds on the link radius, and ctx.lineWidth
are the three knobs; widening the radius multiplies work quadratically, so
raise the grid cell with it.
- Shape: the draw step is a ctx.arc per particle — swap it for rects, images
(drawImage of a sprite), or add a second faint halo arc (that is what the
fireflies glow is) without touching the simulation.
- Palette: pass color="var(--chart-2)" or any token to break away from
currentColor; or set fillStyle per particle from a token ramp if you want a
multi-colour field.
- Budget: MAX_DPR (2), MAX_DT (1/30) and the density cap are the three safety
valves; lowering MAX_DPR to 1 halves fill cost on retina if you ship very
large heroes.Concepts
- Seeded deterministic scatter — every position, velocity, radius and phase comes from a fixed-seed LCG, so the field is identical on every mount and every re-render;
Math.random()never runs, which keeps render pure and screenshots reproducible. - currentColor as ink — the canvas reads its own computed
colorand hands the string to the 2D context; alpha rides onglobalAlpha. Puttext-primaryon the container and the whole field recolors, dark mode included, with zero color literals in the source. - Spatial grid neighbour query — the
linksvariant buckets particles into cells the size of the link radius and compares each one against its own bucket's tail plus 4 of the 8 neighbouring cells, visiting each pair once:O(n·k)instead ofO(n²), which is the difference between 200 particles at 60fps and a stutter. - Position-space pointer force — the cursor displaces particles instead of accelerating them, so the field relaxes back to its base drift the moment the pointer leaves; no energy accumulates and nothing ever escapes at high speed.
- Offscreen pause — an
IntersectionObserverplusvisibilitychangegate the rAF loop, so a background that has scrolled away or a backgrounded tab costs nothing; the time base resets on resume so the field never teleports. - Reduced-motion still frame — under
prefers-reduced-motion: reducethe loop never starts and the pointer is ignored: you still get a full, theme-colored scatter, just one frame of it. - DPR-aware backing store — the canvas is sized
css × devicePixelRatio(capped at 2) with the transform re-applied after every resize, because changingcanvas.widthresets the context; all drawing stays in CSS pixels.