{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particles",
  "type": "registry:ui",
  "title": "Particles",
  "description": "An ambient `<canvas>` particle field for hero and section backdrops: `quantity` dots (100 by default) drifting on their own velocities, fading in near the middle and out near the edges, respawned whenever one leaves the box.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/magicui/particles.tsx",
      "target": "components/ui/magicui/particles.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\n\n// @interlace/particles v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/particles\n// What changed since: https://ds.interlace.tools/c/particles#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Particles\n *\n * An ambient `<canvas>` particle field for hero and section backdrops:\n * `quantity` dots (100 by default) drifting on their own velocities, fading in\n * near the middle and out near the edges, respawned whenever one leaves the box.\n *\n * Decorative by contract — `aria-hidden` and `pointer-events-none`, so it never\n * reaches assistive tech and never intercepts a click.\n *\n * ## Provenance\n *\n * Our reimplementation of the Magic UI `Particles`. What is ours: colour comes\n * from the element's resolved `currentColor` (`getComputedStyle(el).color`,\n * read at mount) rather than a hex `color` prop, so you retint with\n * `className=\"text-primary\"`; the pointer listener is local and opt-out rather\n * than a global `window` handler; and the reduced-motion frame below.\n *\n * ## Anatomy\n *\n *   Particles                        (div — data-slot=\"particles\", aria-hidden,\n *                                     pointer-events-none, text-foreground)\n *     └─ canvas                      (size-full, sized to the wrapper × dpr)\n *\n * One `useEffect` owns the whole canvas lifecycle — sizing, seeding, the\n * animation loop, pointer tracking and a 200ms-debounced resize — and\n * `ParticlesHandle.refresh()` reseeds the field imperatively without a\n * re-render.\n *\n * ## Motion\n *\n * JS-driven: a `requestAnimationFrame` loop repainting a canvas, which no CSS\n * `prefers-reduced-motion` rule can touch. It is gated in JS. Under `reduce`\n * the effect calls `resize()` (which seeds the field), then paints every\n * circle once at its full `targetAlpha` and never calls `tick()`, so the\n * texture is present and completely still. The pointer listener is skipped as\n * well: `trackPointer = interactive && !reducedMotion`.\n *\n * ## Two things to know before you rely on it\n *\n * - The field is `Math.random()`-seeded on every mount — position, size,\n *   alpha, velocity and magnetism. Two renders never match, so it cannot be\n *   snapshot-tested and there is no seed prop.\n * - `interactive` listens on the wrapper's PARENT, not on the wrapper. The\n *   wrapper is `pointer-events-none` by contract, which also makes it a\n *   non-hit-target, so a listener there could never fire — `interactive`,\n *   `staticity` and `ease` were all inert. Drop the field into the positioned\n *   container it is meant to cover (`<div className=\"relative\">`) and that\n *   container is what tracks the pointer; with no parent element it falls back\n *   to `window`.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/** Imperative handle for forcing a fresh particle field (e.g. on route change). */\nexport interface ParticlesHandle {\n  /** Clear and regenerate the particle field at the current canvas size. */\n  refresh: () => void;\n}\n\ninterface ParticlesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Optional CSS class name. Set a Tailwind text token here to recolor the\n   * particles (they paint in the resolved `currentColor`), e.g.\n   * `className=\"text-primary\"`.\n   */\n  className?: string;\n  /**\n   * Number of particles to render.\n   * @default 100\n   */\n  quantity?: number;\n  /**\n   * Resistance to the pointer — higher values make particles drift less\n   * toward the cursor. Has no effect when `interactive` is `false`.\n   * @default 50\n   */\n  staticity?: number;\n  /**\n   * Easing factor for pointer-follow interpolation — higher values make the\n   * drift slower and smoother.\n   * @default 50\n   */\n  ease?: number;\n  /**\n   * Base particle radius in CSS pixels. Each particle adds 0–2px of jitter\n   * on top of this floor.\n   * @default 0.4\n   */\n  size?: number;\n  /**\n   * Horizontal ambient drift applied to every particle each frame.\n   * @default 0\n   */\n  vx?: number;\n  /**\n   * Vertical ambient drift applied to every particle each frame.\n   * @default 0\n   */\n  vy?: number;\n  /**\n   * Whether particles drift toward the pointer as it moves over the field.\n   * Turn off for a purely ambient backdrop (also skips the listener).\n   * @default true\n   */\n  interactive?: boolean;\n}\n\ntype Circle = {\n  x: number;\n  y: number;\n  translateX: number;\n  translateY: number;\n  size: number;\n  alpha: number;\n  targetAlpha: number;\n  dx: number;\n  dy: number;\n  magnetism: number;\n};\n\n/**\n * Reads the canvas wrapper's resolved text color and returns its RGB channels.\n *\n * `getComputedStyle().color` always serializes to `rgb()` / `rgba()`, so we can\n * parse the channels directly without ever embedding a color literal in source.\n * Falls back to opaque white channels if parsing fails (e.g. `transparent`),\n * keeping the canvas visible against a dark backdrop.\n */\nfunction readRgbChannels(el: HTMLElement | null): [number, number, number] {\n  const fallback: [number, number, number] = [255, 255, 255];\n  if (!el || typeof window === \"undefined\") return fallback;\n  const resolved = window.getComputedStyle(el).color;\n  const match = resolved.match(/\\d+(\\.\\d+)?/g);\n  if (!match || match.length < 3) return fallback;\n  return [Number(match[0]), Number(match[1]), Number(match[2])];\n}\n\nfunction remapValue(\n  value: number,\n  start1: number,\n  end1: number,\n  start2: number,\n  end2: number,\n): number {\n  const remapped =\n    ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;\n  return remapped > 0 ? remapped : 0;\n}\n\n/**\n * Particles — an ambient, token-driven particle field for hero and section\n * backdrops. Decorative-only: `aria-hidden` and `pointer-events-none`, so it\n * never reaches the accessibility tree or intercepts input.\n *\n * Motion control: respects `prefers-reduced-motion: reduce` — when set, it\n * paints one static frame instead of animating, so reduced-motion users still\n * see the texture without the movement.\n *\n * @example\n * ```tsx\n * <div className=\"relative\">\n *   <Particles\n *     data-testid=\"hero-particles\"\n *     className=\"absolute inset-0 text-primary\"\n *     quantity={120}\n *   />\n * </div>\n * ```\n */\nexport const Particles = forwardRef<ParticlesHandle, ParticlesProps>(\n  function Particles(\n    {\n      className,\n      quantity = 100,\n      staticity = 50,\n      ease = 50,\n      size = 0.4,\n      vx = 0,\n      vy = 0,\n      interactive = true,\n      ...props\n    },\n    handleRef,\n  ) {\n    const reducedMotion = useReducedMotion();\n    const containerRef = useRef<HTMLDivElement>(null);\n    const canvasRef = useRef<HTMLCanvasElement>(null);\n    const context = useRef<CanvasRenderingContext2D | null>(null);\n    const circles = useRef<Circle[]>([]);\n    const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });\n    const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });\n    const rgb = useRef<[number, number, number]>([255, 255, 255]);\n    const rafId = useRef<number | null>(null);\n\n    const circleParams = useCallback((): Circle => {\n      const x = Math.floor(Math.random() * canvasSize.current.w);\n      const y = Math.floor(Math.random() * canvasSize.current.h);\n      const pSize = Math.floor(Math.random() * 2) + size;\n      const targetAlpha = Number((Math.random() * 0.6 + 0.1).toFixed(1));\n      const dx = (Math.random() - 0.5) * 0.1;\n      const dy = (Math.random() - 0.5) * 0.1;\n      const magnetism = 0.1 + Math.random() * 4;\n      return {\n        x,\n        y,\n        translateX: 0,\n        translateY: 0,\n        size: pSize,\n        alpha: 0,\n        targetAlpha,\n        dx,\n        dy,\n        magnetism,\n      };\n    }, [size]);\n\n    const drawCircle = useCallback(\n      (circle: Circle, dpr: number, push = true) => {\n        const ctx = context.current;\n        if (!ctx) return;\n        const { x, y, translateX, translateY, size: pSize, alpha } = circle;\n        ctx.translate(translateX, translateY);\n        ctx.beginPath();\n        ctx.arc(x, y, pSize, 0, 2 * Math.PI);\n        const [r, g, b] = rgb.current;\n        ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;\n        ctx.fill();\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        if (push) circles.current.push(circle);\n      },\n      [],\n    );\n\n    // One effect owns the entire canvas lifecycle: sizing, drawing, the\n    // animation loop, pointer tracking, and resize handling. Re-runs when a\n    // tuning prop that affects the field changes. `refresh` is exposed\n    // imperatively rather than as a re-render trigger.\n    useEffect(() => {\n      const container = containerRef.current;\n      const canvas = canvasRef.current;\n      if (!container || !canvas) return;\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return;\n      context.current = ctx;\n\n      const dpr =\n        typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n      rgb.current = readRgbChannels(container);\n\n      const clear = () =>\n        ctx.clearRect(0, 0, canvasSize.current.w, canvasSize.current.h);\n\n      const seed = () => {\n        circles.current = [];\n        for (let i = 0; i < quantity; i++) {\n          drawCircle(circleParams(), dpr);\n        }\n      };\n\n      const resize = () => {\n        canvasSize.current.w = container.offsetWidth;\n        canvasSize.current.h = container.offsetHeight;\n        canvas.width = canvasSize.current.w * dpr;\n        canvas.height = canvasSize.current.h * dpr;\n        canvas.style.width = `${canvasSize.current.w}px`;\n        canvas.style.height = `${canvasSize.current.h}px`;\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        seed();\n      };\n\n      const tick = () => {\n        clear();\n        circles.current.forEach((circle, i) => {\n          const edges = [\n            circle.x + circle.translateX - circle.size,\n            canvasSize.current.w - circle.x - circle.translateX - circle.size,\n            circle.y + circle.translateY - circle.size,\n            canvasSize.current.h - circle.y - circle.translateY - circle.size,\n          ];\n          const closestEdge = edges.reduce((a, b) => Math.min(a, b));\n          const remapped = Number(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2));\n          if (remapped > 1) {\n            circle.alpha += 0.02;\n            if (circle.alpha > circle.targetAlpha) {\n              circle.alpha = circle.targetAlpha;\n            }\n          } else {\n            circle.alpha = circle.targetAlpha * remapped;\n          }\n          circle.x += circle.dx + vx;\n          circle.y += circle.dy + vy;\n          circle.translateX +=\n            (mouse.current.x / (staticity / circle.magnetism) -\n              circle.translateX) /\n            ease;\n          circle.translateY +=\n            (mouse.current.y / (staticity / circle.magnetism) -\n              circle.translateY) /\n            ease;\n\n          drawCircle(circle, dpr, false);\n\n          if (\n            circle.x < -circle.size ||\n            circle.x > canvasSize.current.w + circle.size ||\n            circle.y < -circle.size ||\n            circle.y > canvasSize.current.h + circle.size\n          ) {\n            circles.current.splice(i, 1);\n            drawCircle(circleParams(), dpr);\n          }\n        });\n        rafId.current = window.requestAnimationFrame(tick);\n      };\n\n      resize();\n\n      if (reducedMotion) {\n        // Reduced motion: paint a single static frame at full target alpha so\n        // the texture is present without any movement.\n        clear();\n        circles.current.forEach((circle) => {\n          circle.alpha = circle.targetAlpha;\n          drawCircle(circle, dpr, false);\n        });\n      } else {\n        tick();\n      }\n\n      const onPointerMove = (event: PointerEvent) => {\n        const rect = canvas.getBoundingClientRect();\n        const { w, h } = canvasSize.current;\n        const x = event.clientX - rect.left - w / 2;\n        const y = event.clientY - rect.top - h / 2;\n        if (x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2) {\n          mouse.current.x = x;\n          mouse.current.y = y;\n        }\n      };\n\n      // The wrapper is `pointer-events-none` — by contract, so the field never\n      // eats a click — which also means it is never a hit target and a\n      // `pointermove` listener on it can never fire. Listen on the positioned\n      // parent the field was dropped into instead: still local (not a global\n      // `window` handler), still scoped to the surface the particles cover.\n      // `window` is the fallback for a field mounted with no parent element.\n      //\n      // The coordinate maths is unaffected — it is computed from the CANVAS's\n      // own `getBoundingClientRect()`, not from the element that heard the\n      // event, and the in-bounds check below already discards anything outside\n      // the field.\n      const pointerTarget: Pick<\n        HTMLElement,\n        \"addEventListener\" | \"removeEventListener\"\n      > = container.parentElement ?? window;\n\n      let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n      const onResize = () => {\n        if (resizeTimer) clearTimeout(resizeTimer);\n        resizeTimer = setTimeout(resize, 200);\n      };\n\n      const trackPointer = interactive && !reducedMotion;\n      if (trackPointer) {\n        pointerTarget.addEventListener(\"pointermove\", onPointerMove, {\n          passive: true,\n        });\n      }\n      window.addEventListener(\"resize\", onResize);\n\n      return () => {\n        if (rafId.current != null) window.cancelAnimationFrame(rafId.current);\n        if (resizeTimer) clearTimeout(resizeTimer);\n        if (trackPointer) {\n          pointerTarget.removeEventListener(\"pointermove\", onPointerMove);\n        }\n        window.removeEventListener(\"resize\", onResize);\n      };\n    }, [\n      quantity,\n      staticity,\n      ease,\n      vx,\n      vy,\n      interactive,\n      reducedMotion,\n      circleParams,\n      drawCircle,\n    ]);\n\n    useImperativeHandle(\n      handleRef,\n      (): ParticlesHandle => ({\n        refresh: () => {\n          const container = containerRef.current;\n          const ctx = context.current;\n          if (!container || !ctx) return;\n          const dpr =\n            typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n          rgb.current = readRgbChannels(container);\n          ctx.clearRect(0, 0, canvasSize.current.w, canvasSize.current.h);\n          circles.current = [];\n          for (let i = 0; i < quantity; i++) {\n            drawCircle(circleParams(), dpr);\n          }\n        },\n      }),\n      [quantity, circleParams, drawCircle],\n    );\n\n    return (\n      <div\n        ref={containerRef}\n        data-slot=\"particles\"\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none text-foreground\",\n          className,\n        )}\n        {...props}\n      >\n        <canvas ref={canvasRef} className=\"size-full\" />\n      </div>\n    );\n  },\n);\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/particles\n\nInstalled to `components/ui/magicui/particles.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/magicui/particles';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/particles\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
