Backgrounds

Particles

An interactive canvas particle field — dots, linked constellations, snow or fireflies, painted in your text token and scattered by the pointer.

Preview in your theme

Loading preview…

"use client"

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

export type ParticlesVariant = "dots" | "links" | "snow" | "fireflies"

/** Nominal drift speed per variant, in CSS px/s before the `speed` multiplier. */
const BASE_SPEED: Record<ParticlesVariant, number> = {
  dots: 26,
  links: 20,
  snow: 36,
  fireflies: 14,
}

Installation

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

Prompt

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 color and hands the string to the 2D context; alpha rides on globalAlpha. Put text-primary on the container and the whole field recolors, dark mode included, with zero color literals in the source.
  • Spatial grid neighbour query — the links variant 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 of O(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 IntersectionObserver plus visibilitychange gate 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: reduce the 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 changing canvas.width resets the context; all drawing stays in CSS pixels.

On This Page