# Particles Background

> A canvas particle network that reacts to the mouse and pauses when off-screen or the tab is hidden.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/particles-background
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/particles-background.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/particles-background-demo.tsx"
import { ParticlesBackground } from "@/components/particles-background"

export default function ParticlesBackgroundDemo() {
  return (
    <ParticlesBackground className="flex h-96 w-full max-w-3xl items-center justify-center rounded-3xl border bg-zinc-950 text-sky-200">
      <div className="pointer-events-none relative z-10 px-6 text-center">
        <h3 className="text-3xl font-semibold tracking-tight text-white">Everything is connected</h3>
        <p className="mt-2 text-sm text-zinc-400">Move your cursor through the network.</p>
      </div>
    </ParticlesBackground>
  )
}
```

## Source

```tsx title="components/particles-background.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

interface Dot {
  x: number
  y: number
  vx: number
  vy: number
  r: number
}

export interface ParticlesBackgroundProps extends React.ComponentProps<"div"> {
  /** Particles per 10,000 px² of area */
  density?: number
  /** Max distance in px at which two dots are linked */
  linkDistance?: number
  /** Drift speed multiplier */
  speed?: number
  /** Dot and line color, any CSS color. Defaults to the inherited text color */
  color?: string
  /** Radius in px of the pointer's attract/link zone. 0 disables it */
  mouseRadius?: number
}

// Fills its (relative) parent with a canvas. The loop runs only while the
// canvas is on screen and the tab is visible, and the buffer follows DPR.
export function ParticlesBackground({
  density = 1.1,
  linkDistance = 120,
  speed = 1,
  color = "currentColor",
  mouseRadius = 160,
  className,
  children,
  ...props
}: ParticlesBackgroundProps) {
  const wrapRef = React.useRef<HTMLDivElement>(null)
  const canvasRef = React.useRef<HTMLCanvasElement>(null)

  React.useEffect(() => {
    const wrap = wrapRef.current
    const canvas = canvasRef.current
    const ctx = canvas?.getContext("2d")
    if (!wrap || !canvas || !ctx) return

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
    let dots: Dot[] = []
    let w = 0
    let h = 0
    let raf = 0
    let inView = false
    const mouse = { x: -9999, y: -9999 }

    // currentColor resolves through the canvas' computed style
    const resolveColor = () => {
      const probe = getComputedStyle(canvas)
      return color === "currentColor" ? probe.color : color
    }
    let ink = resolveColor()

    const seed = () => {
      const count = Math.min(220, Math.round(((w * h) / 10000) * density))
      dots = Array.from({ length: count }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        vx: (Math.random() - 0.5) * 0.5,
        vy: (Math.random() - 0.5) * 0.5,
        r: 0.8 + Math.random() * 1.4,
      }))
    }

    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      w = wrap.clientWidth
      h = wrap.clientHeight
      canvas.width = Math.max(1, Math.round(w * dpr))
      canvas.height = Math.max(1, Math.round(h * dpr))
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
      seed()
      ink = resolveColor()
      draw()
    }

    const step = () => {
      for (const d of dots) {
        d.x += d.vx * speed
        d.y += d.vy * speed
        if (d.x < 0 || d.x > w) d.vx *= -1
        if (d.y < 0 || d.y > h) d.vy *= -1
        if (mouseRadius > 0) {
          const dx = mouse.x - d.x
          const dy = mouse.y - d.y
          const dist = Math.hypot(dx, dy)
          if (dist < mouseRadius && dist > 1) {
            const pull = (1 - dist / mouseRadius) * 0.03
            d.x += dx * pull
            d.y += dy * pull
          }
        }
        d.x = Math.min(Math.max(d.x, 0), w)
        d.y = Math.min(Math.max(d.y, 0), h)
      }
    }

    const draw = () => {
      ctx.clearRect(0, 0, w, h)
      ctx.fillStyle = ink
      ctx.strokeStyle = ink
      ctx.lineWidth = 1
      for (let i = 0; i < dots.length; i++) {
        const a = dots[i]
        ctx.globalAlpha = 0.7
        ctx.beginPath()
        ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2)
        ctx.fill()
        for (let j = i + 1; j < dots.length; j++) {
          const b = dots[j]
          const dist = Math.hypot(a.x - b.x, a.y - b.y)
          if (dist < linkDistance) {
            ctx.globalAlpha = (1 - dist / linkDistance) * 0.35
            ctx.beginPath()
            ctx.moveTo(a.x, a.y)
            ctx.lineTo(b.x, b.y)
            ctx.stroke()
          }
        }
        if (mouseRadius > 0) {
          const dist = Math.hypot(a.x - mouse.x, a.y - mouse.y)
          if (dist < mouseRadius) {
            ctx.globalAlpha = (1 - dist / mouseRadius) * 0.6
            ctx.beginPath()
            ctx.moveTo(a.x, a.y)
            ctx.lineTo(mouse.x, mouse.y)
            ctx.stroke()
          }
        }
      }
      ctx.globalAlpha = 1
    }

    const frame = () => {
      step()
      draw()
      raf = requestAnimationFrame(frame)
    }

    const sync = () => {
      cancelAnimationFrame(raf)
      raf = 0
      if (!reduced && inView && !document.hidden) raf = requestAnimationFrame(frame)
    }

    const onMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect()
      mouse.x = e.clientX - rect.left
      mouse.y = e.clientY - rect.top
    }
    const onLeave = () => {
      mouse.x = mouse.y = -9999
    }

    const io = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting
      sync()
    })
    const ro = new ResizeObserver(resize)
    const mo = new MutationObserver(() => {
      ink = resolveColor()
      if (!raf) draw()
    })

    io.observe(wrap)
    ro.observe(wrap)
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] })
    document.addEventListener("visibilitychange", sync)
    wrap.addEventListener("pointermove", onMove)
    wrap.addEventListener("pointerleave", onLeave)
    resize()

    return () => {
      cancelAnimationFrame(raf)
      io.disconnect()
      ro.disconnect()
      mo.disconnect()
      document.removeEventListener("visibilitychange", sync)
      wrap.removeEventListener("pointermove", onMove)
      wrap.removeEventListener("pointerleave", onLeave)
    }
  }, [density, linkDistance, speed, color, mouseRadius])

  return (
    <div ref={wrapRef} className={cn("relative overflow-hidden", className)} {...props}>
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
      {children}
    </div>
  )
}
```
