{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particles",
  "title": "Particles",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A canvas field of softly twinkling particles with depth, gentle drift and a pointer that attracts or repels them. Token colors, DPR aware, pauses off-screen, still under reduced motion.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/color.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/particles.tsx",
      "content": "// Ballmac UI: Particles. https://ui.ballmac.com/components/particles\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { observeTheme, resolveCssColor } from \"@/lib/ballmac/color\"\n\ntype ParticlesProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"color\"> & {\n  /** Number of particles. Scaled down on small containers so density stays even. */\n  quantity?: number\n  /** Base particle radius in pixels; each particle varies around it. */\n  size?: number\n  /** Particle color: a CSS variable name (\"--foreground\") or any CSS color. */\n  color?: string\n  /** How the field reacts to the pointer. */\n  interaction?: \"attract\" | \"repel\" | \"none\"\n  /** Radius of pointer influence in pixels. */\n  radius?: number\n  /** Smoothing of the pointer response; higher is slower and softer. */\n  ease?: number\n  /** Constant horizontal drift in pixels per frame. */\n  vx?: number\n  /** Constant vertical drift in pixels per frame (negative rises). */\n  vy?: number\n  /** Maximum particle opacity (0–1). */\n  maxOpacity?: number\n}\n\ntype Particle = {\n  x: number\n  y: number\n  dx: number\n  dy: number\n  r: number\n  alpha: number\n  target: number\n  ox: number\n  oy: number\n  depth: number\n}\n\nfunction Particles({\n  quantity = 120,\n  size = 0.7,\n  color = \"--foreground\",\n  interaction = \"attract\",\n  radius = 140,\n  ease = 24,\n  vx = 0,\n  vy = 0,\n  maxOpacity = 0.7,\n  className,\n  ...props\n}: ParticlesProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!root || !canvas || !ctx) return\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n    let width = 0\n    let height = 0\n    let fill = resolveCssColor(root, color)\n    let particles: Particle[] = []\n    const pointer = { x: -9999, y: -9999, inside: false }\n\n    const spawn = (): Particle => {\n      const depth = Math.random()\n      return {\n        x: Math.random() * width,\n        y: Math.random() * height,\n        dx: (Math.random() - 0.5) * 0.18 * (0.4 + depth),\n        dy: (Math.random() - 0.5) * 0.18 * (0.4 + depth),\n        r: size * (0.5 + depth * 1.3),\n        alpha: 0,\n        target: (0.25 + Math.random() * 0.75) * maxOpacity,\n        ox: 0,\n        oy: 0,\n        depth,\n      }\n    }\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      width = root.clientWidth\n      height = root.clientHeight\n      canvas.width = Math.round(width * dpr)\n      canvas.height = Math.round(height * dpr)\n      canvas.style.width = `${width}px`\n      canvas.style.height = `${height}px`\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      // Density follows area: the given quantity is for a ~ 800×500 surface.\n      const count = Math.max(12, Math.round((quantity * width * height) / (800 * 500)))\n      particles = Array.from({ length: Math.min(count, quantity * 2) }, spawn)\n      if (reduceMotion) for (const p of particles) p.alpha = p.target\n    }\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height)\n      ctx.fillStyle = fill\n      for (const p of particles) {\n        if (!reduceMotion) {\n          p.x += p.dx + vx * (0.5 + p.depth)\n          p.y += p.dy + vy * (0.5 + p.depth)\n          // Wrap around the edges so the field never empties.\n          if (p.x < -10) p.x = width + 10\n          else if (p.x > width + 10) p.x = -10\n          if (p.y < -10) p.y = height + 10\n          else if (p.y > height + 10) p.y = -10\n\n          let tx = 0\n          let ty = 0\n          if (interaction !== \"none\" && pointer.inside) {\n            const ddx = pointer.x - p.x\n            const ddy = pointer.y - p.y\n            const dist = Math.hypot(ddx, ddy)\n            if (dist < radius && dist > 0.001) {\n              const force = (1 - dist / radius) ** 2 * radius * 0.35 * (0.4 + p.depth)\n              const sign = interaction === \"attract\" ? 1 : -1\n              tx = (ddx / dist) * force * sign\n              ty = (ddy / dist) * force * sign\n            }\n          }\n          p.ox += (tx - p.ox) / ease\n          p.oy += (ty - p.oy) / ease\n          // Slow twinkle: ease toward a target opacity, then pick a new one.\n          p.alpha += (p.target - p.alpha) * 0.02\n          if (Math.abs(p.target - p.alpha) < 0.01) p.target = (0.2 + Math.random() * 0.8) * maxOpacity\n        }\n        const x = p.x + p.ox\n        const y = p.y + p.oy\n        // Fade particles near the edges.\n        const edge = Math.min(x, y, width - x, height - y)\n        const fade = Math.min(Math.max(edge / 40, 0), 1)\n        ctx.globalAlpha = p.alpha * fade\n        ctx.beginPath()\n        ctx.arc(x, y, p.r, 0, Math.PI * 2)\n        ctx.fill()\n      }\n      ctx.globalAlpha = 1\n    }\n\n    let frame = 0\n    let visible = true\n    const loop = () => {\n      draw()\n      frame = requestAnimationFrame(loop)\n    }\n    const sync = () => {\n      cancelAnimationFrame(frame)\n      if (reduceMotion) draw()\n      else if (visible && !document.hidden) frame = requestAnimationFrame(loop)\n    }\n\n    const onPointerMove = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect()\n      pointer.x = event.clientX - rect.left\n      pointer.y = event.clientY - rect.top\n      pointer.inside = pointer.x >= 0 && pointer.y >= 0 && pointer.x <= rect.width && pointer.y <= rect.height\n    }\n    const onPointerLeave = () => {\n      pointer.inside = false\n    }\n\n    resize()\n    sync()\n    const ro = new ResizeObserver(() => {\n      resize()\n      if (reduceMotion) draw()\n    })\n    ro.observe(root)\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true\n      sync()\n    })\n    io.observe(root)\n    const stopTheme = observeTheme(() => {\n      fill = resolveCssColor(root, color)\n      if (reduceMotion) draw()\n    })\n    document.addEventListener(\"visibilitychange\", sync)\n    window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n    document.documentElement.addEventListener(\"pointerleave\", onPointerLeave)\n    return () => {\n      cancelAnimationFrame(frame)\n      ro.disconnect()\n      io.disconnect()\n      stopTheme()\n      document.removeEventListener(\"visibilitychange\", sync)\n      window.removeEventListener(\"pointermove\", onPointerMove)\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave)\n    }\n  }, [quantity, size, color, interaction, radius, ease, vx, vy, maxOpacity])\n\n  return (\n    <div\n      ref={rootRef}\n      aria-hidden=\"true\"\n      data-slot=\"particles\"\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block\" />\n    </div>\n  )\n}\n\nexport { Particles, type ParticlesProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/particles.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "particles",
      "stars",
      "canvas",
      "background",
      "hero",
      "interactive"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Place <Particles /> as the first child of a relative, overflow-hidden container; it fills it (absolute inset-0, aria-hidden, pointer-events-none) and tracks the pointer at window level, so content above it stays clickable.",
      "whenToUse": [
        "Quiet hero or CTA backgrounds, especially on dark surfaces",
        "Space, AI or data themed sections that need depth without imagery"
      ],
      "whenNotToUse": [
        "Behind long-form text",
        "Many instances on one page (each runs its own canvas loop)"
      ],
      "composesWith": [
        "text-reveal",
        "shimmer-text",
        "magnetic-button"
      ],
      "a11y": [],
      "customization": [
        "quantity (per ~800×500 px, scales with area), size (base radius px), maxOpacity",
        "color: CSS variable name or color, default --foreground; try --chart-1",
        "interaction: attract | repel | none; radius and ease shape the pointer response",
        "vx / vy add a constant drift (vy={-0.3} rises like embers)"
      ]
    },
    "examples": [
      "particles-demo",
      "particles-rising"
    ],
    "url": "https://ui.ballmac.com/components/particles"
  },
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}