{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-3d-particles",
  "type": "registry:ui",
  "title": "Floating 3D Particles",
  "description": "A canvas-based pseudo-3D particle field with perspective projection, continuous rotation and buoyant drift.",
  "files": [
    {
      "path": "registry/magicui/floating-3d-particles.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface Floating3DParticlesProps extends Omit<\n  React.CanvasHTMLAttributes<HTMLCanvasElement>,\n  \"width\" | \"height\"\n> {\n  /**\n   * Number of particles rendered on desktop viewports.\n   * On mobile screens (< 768px), particle count automatically scales down\n   * to 20 % to preserve smooth frame rates.\n   * @default 400\n   */\n  quantity?: number\n  /**\n   * Particle color — 3 or 6 digit hex string.\n   * @default \"#8B5CF6\"\n   */\n  color?: string\n  /**\n   * Mean particle radius in px. Each particle is randomly assigned a size\n   * within ±40 % of this value.\n   * @default 5\n   */\n  size?: number\n  /**\n   * Mean particle opacity (0–1). Each particle randomly varies within\n   * ±0.2 of this value, clamped to [0, 1].\n   * @default 0.3\n   */\n  opacity?: number\n  /**\n   * Vertical floating speed in px per frame. Positive values drift upward,\n   * negative values downward.\n   * @default 0.8\n   */\n  drift?: number\n  /**\n   * 3-D depth intensity on a 0–1 scale. `0` produces a flat 2D plane; `1`\n   * creates strong perspective with pronounced near/far scaling.\n   * @default 0.5\n   */\n  depth?: number\n}\n\n// ---------------------------------------------------------------------------\n// Particle State (Polar coordinates + 3D perspective projection)\n// ---------------------------------------------------------------------------\n\ninterface Particle {\n  angle: number\n  radius: number\n  y: number\n  size: number\n  angularSpeed: number\n  opacity: number\n  screenX: number\n  screenY: number\n  projectedScale: number\n}\n\n// ---------------------------------------------------------------------------\n// Constants & Helpers\n// ---------------------------------------------------------------------------\n\nconst MOBILE_BREAKPOINT = 768\nconst SPREAD_FACTOR = 1.2\nconst MAX_DPR = 2\n\nfunction hexToRgba(hex: string, alpha: number) {\n  const clean = hex.replace(\"#\", \"\").trim()\n  const full =\n    clean.length === 3\n      ? clean\n          .split(\"\")\n          .map((c) => c + c)\n          .join(\"\")\n      : clean\n\n  if (!/^[0-9a-f]{6}$/i.test(full)) return `rgba(139,92,246,${alpha})`\n\n  const n = Number.parseInt(full, 16)\n  return `rgba(${(n >> 16) & 0xff},${(n >> 8) & 0xff},${n & 0xff},${alpha})`\n}\n\nfunction deriveProjection(depth: number) {\n  const t = Math.max(0, Math.min(1, depth))\n  const fov = 800 - t * 600 // [800, 200]\n  const perspectiveDistance = 100 + t * 700 // [100, 800]\n  // depthRange stays below fov + pd - 1 to ensure positive denominator\n  const depthRange = t * Math.min(400, fov + perspectiveDistance - 1)\n  return { fov, perspectiveDistance, depthRange }\n}\n\nfunction spawnParticle(\n  width: number,\n  height: number,\n  size: number,\n  opacity: number\n): Particle {\n  const sizeVariance = size * 0.4\n  const opacityVariance = 0.2\n  return {\n    angle: Math.random() * Math.PI * 2,\n    radius: Math.random() * Math.max(width, height) * SPREAD_FACTOR,\n    y: (Math.random() - 0.5) * height * 2,\n    size: Math.max(0.5, size - sizeVariance + Math.random() * sizeVariance * 2),\n    angularSpeed: 0.0015 + Math.random() * 0.001,\n    opacity: Math.min(\n      1,\n      Math.max(\n        0,\n        opacity - opacityVariance + Math.random() * opacityVariance * 2\n      )\n    ),\n    screenX: 0,\n    screenY: 0,\n    projectedScale: 1,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport function Floating3DParticles({\n  quantity = 400,\n  color = \"#8B5CF6\",\n  size = 5,\n  opacity = 0.3,\n  drift = 0.8,\n  depth = 0.5,\n  className,\n  style,\n  ...canvasProps\n}: Floating3DParticlesProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const ioRef = React.useRef<IntersectionObserver | null>(null)\n  const stateRef = React.useRef({\n    mounted: false,\n    paused: false,\n    reducedMotion: false,\n    rafId: null as number | null,\n  })\n\n  // Read fresh on every frame, so recolouring (e.g. a theme switch) does not\n  // tear down the effect and respawn the whole field.\n  const colorRef = React.useRef(color)\n  colorRef.current = color\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\", { alpha: true })\n    if (!ctx) return\n\n    const s = stateRef.current\n    s.mounted = true\n    s.paused = false\n\n    let width = 0\n    let height = 0\n    let particles: Particle[] = []\n    let staticDirty = true\n\n    const { fov, perspectiveDistance, depthRange } = deriveProjection(depth)\n\n    // Reduced-motion media query (built-in a11y best practice)\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const syncReducedMotion = () => {\n      s.reducedMotion = mq.matches\n    }\n    syncReducedMotion()\n\n    // Draw single particle\n    const draw = (p: Particle) => {\n      const r = Math.max(0, p.size * p.projectedScale)\n      if (r <= 0) return\n\n      ctx.beginPath()\n      ctx.fillStyle = hexToRgba(colorRef.current, p.opacity)\n      ctx.arc(p.screenX, p.screenY, r, 0, Math.PI * 2)\n      ctx.fill()\n    }\n\n    // Static frame for reduced motion\n    const staticFrame = () => {\n      ctx.clearRect(0, 0, width, height)\n      const cx = width / 2\n      const cy = height / 2\n\n      for (const p of particles) {\n        const denom = Math.max(1, fov + perspectiveDistance)\n        const scale = fov / denom\n        p.screenX = cx + Math.cos(p.angle) * p.radius * scale\n        p.screenY = cy + p.y * scale\n        p.projectedScale = scale\n        draw(p)\n      }\n    }\n\n    // Animation loop: continuous 3D field rotation and buoyant drift\n    const tick = () => {\n      if (!s.mounted) return\n\n      if (s.paused) {\n        s.rafId = requestAnimationFrame(tick)\n        return\n      }\n\n      // Reduced motion: paint one static frame, then idle until something\n      // invalidates it (resize, or the motion preference being turned off).\n      if (s.reducedMotion) {\n        if (staticDirty) {\n          staticDirty = false\n          staticFrame()\n        }\n        s.rafId = requestAnimationFrame(tick)\n        return\n      }\n\n      staticDirty = true\n\n      ctx.clearRect(0, 0, width, height)\n\n      const cx = width / 2\n      const cy = height / 2\n\n      for (const p of particles) {\n        // Continuous organic rotation around the 3D central axis\n        p.angle += p.angularSpeed\n        p.y -= drift\n\n        // Respawn on the opposite edge, whichever one the particle left\n        // through, so a negative drift falls instead of draining the field\n        if (p.y < -height) {\n          p.y = height\n          p.radius = Math.random() * Math.max(width, height) * SPREAD_FACTOR\n        } else if (p.y > height) {\n          p.y = -height\n          p.radius = Math.random() * Math.max(width, height) * SPREAD_FACTOR\n        }\n\n        // 3D Perspective Projection\n        const denom = Math.max(\n          1,\n          fov + perspectiveDistance + Math.sin(p.angle) * depthRange\n        )\n        const scale = fov / denom\n\n        p.screenX = cx + Math.cos(p.angle) * p.radius * scale\n        p.screenY = cy + p.y * scale\n        p.projectedScale = scale\n      }\n\n      // Painter's algorithm: draw farthest particles first\n      particles.sort((a, b) => a.projectedScale - b.projectedScale)\n      for (const p of particles) draw(p)\n\n      s.rafId = requestAnimationFrame(tick)\n    }\n\n    // Resize handling with automatic mobile count scaling\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect()\n      width = Math.max(1, Math.round(rect.width))\n      height = Math.max(1, Math.round(rect.height))\n\n      const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, MAX_DPR))\n      canvas.width = Math.round(width * dpr)\n      canvas.height = Math.round(height * dpr)\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n\n      const isMobile = window.innerWidth < MOBILE_BREAKPOINT\n      const count = isMobile ? Math.round(quantity * 0.2) : quantity\n\n      particles = Array.from({ length: Math.max(0, count) }, () =>\n        spawnParticle(width, height, size, opacity)\n      )\n\n      // Canvas was cleared by the width/height reassignment above.\n      staticDirty = true\n    }\n\n    const onVisibilityChange = () => {\n      s.paused = document.hidden\n    }\n\n    // ResizeObserver\n    const ro =\n      typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null\n    if (ro) {\n      ro.observe(canvas)\n    } else {\n      window.addEventListener(\"resize\", resize)\n    }\n\n    // IntersectionObserver (built-in performance pause off-screen)\n    if (typeof IntersectionObserver !== \"undefined\") {\n      ioRef.current = new IntersectionObserver(\n        ([entry]) => {\n          if (entry) s.paused = document.hidden || !entry.isIntersecting\n        },\n        { threshold: 0 }\n      )\n      ioRef.current.observe(canvas)\n    }\n\n    document.addEventListener(\"visibilitychange\", onVisibilityChange)\n    mq.addEventListener(\"change\", syncReducedMotion)\n\n    resize()\n    s.rafId = requestAnimationFrame(tick)\n\n    return () => {\n      s.mounted = false\n      if (s.rafId !== null) {\n        cancelAnimationFrame(s.rafId)\n        s.rafId = null\n      }\n      ro?.disconnect()\n      if (!ro) window.removeEventListener(\"resize\", resize)\n      ioRef.current?.disconnect()\n      ioRef.current = null\n      document.removeEventListener(\"visibilitychange\", onVisibilityChange)\n      mq.removeEventListener(\"change\", syncReducedMotion)\n    }\n  }, [quantity, size, opacity, drift, depth])\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 h-full w-full\",\n        className\n      )}\n      style={style}\n      {...canvasProps}\n    />\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}