{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-dots",
  "type": "registry:ui",
  "title": "Floating Dots",
  "description": "A Floating Dots component for displaying a set of floating animated dots.",
  "files": [
    {
      "path": "src/registry/ui/floating-dots.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Dot = { x: number; y: number; radius: number; speed: number };\n\nexport type FloatingDotsProps = Omit<React.ComponentProps<\"canvas\">, \"ref\" | \"children\"> & {\n  /** Number of dots. @default 50 */\n  count?: number;\n  /** Any CSS color. Defaults to the canvas' current text color (`currentColor`). */\n  color?: string;\n  /** Minimum dot radius in CSS pixels. @default 1 */\n  minRadius?: number;\n  /** Maximum dot radius in CSS pixels. @default 3 */\n  maxRadius?: number;\n  /** Minimum upward speed in CSS pixels per frame (at 60fps). @default 0.5 */\n  minSpeed?: number;\n  /** Maximum upward speed in CSS pixels per frame (at 60fps). @default 2 */\n  maxSpeed?: number;\n};\n\nconst TAU = Math.PI * 2;\nconst FRAME_MS = 1000 / 60;\n\n/**\n * Dots drifting upward on a canvas that fills its positioned parent.\n * Pauses offscreen and draws a single static frame when reduced motion is preferred.\n */\nexport function FloatingDots({\n  count = 50,\n  color,\n  minRadius = 1,\n  maxRadius = 3,\n  minSpeed = 0.5,\n  maxSpeed = 2,\n  className,\n  ...props\n}: FloatingDotsProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const fillRef = useRef(\"currentColor\");\n  const redrawRef = useRef<() => void>(() => {});\n\n  // Resolve the fill color. Without an explicit color, follow the inherited text color,\n  // re-reading it when the theme class/attributes on <html> change.\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    const update = () => {\n      fillRef.current = color ?? getComputedStyle(canvas).color;\n      redrawRef.current();\n    };\n    update();\n    if (color) return;\n\n    const observer = new MutationObserver(update);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    });\n    return () => observer.disconnect();\n  }, [color]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const parent = canvas?.parentElement;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !parent || !ctx) return;\n\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const dots: Dot[] = [];\n    let width = 0;\n    let height = 0;\n    let frame = 0;\n    let lastTime = 0;\n    let inView = true;\n\n    const between = (min: number, max: number) => min + Math.random() * (max - min);\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = fillRef.current;\n      ctx.beginPath();\n      for (const dot of dots) {\n        ctx.moveTo(dot.x + dot.radius, dot.y);\n        ctx.arc(dot.x, dot.y, dot.radius, 0, TAU);\n      }\n      ctx.fill();\n    };\n\n    const tick = (time: number) => {\n      const step = lastTime ? Math.min((time - lastTime) / FRAME_MS, 3) : 1;\n      lastTime = time;\n      for (const dot of dots) {\n        dot.y -= dot.speed * step;\n        if (dot.y < -dot.radius * 2) {\n          dot.x = Math.random() * width;\n          dot.y = height + dot.radius;\n        }\n      }\n      draw();\n      frame = requestAnimationFrame(tick);\n    };\n\n    const start = () => {\n      if (frame || !inView || reducedMotion.matches || !width || !height) return;\n      lastTime = 0;\n      frame = requestAnimationFrame(tick);\n    };\n\n    const stop = () => {\n      cancelAnimationFrame(frame);\n      frame = 0;\n    };\n\n    const resize = () => {\n      const nextWidth = parent.clientWidth;\n      const nextHeight = parent.clientHeight;\n      if (!nextWidth || !nextHeight) return;\n\n      if (dots.length === 0) {\n        for (let i = 0; i < count; i++) {\n          dots.push({\n            x: Math.random() * nextWidth,\n            y: Math.random() * nextHeight,\n            radius: between(minRadius, maxRadius),\n            speed: between(minSpeed, maxSpeed),\n          });\n        }\n      } else if (width && height) {\n        const scaleX = nextWidth / width;\n        const scaleY = nextHeight / height;\n        for (const dot of dots) {\n          dot.x *= scaleX;\n          dot.y *= scaleY;\n        }\n      }\n\n      width = nextWidth;\n      height = nextHeight;\n      const dpr = window.devicePixelRatio || 1;\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      draw();\n      start();\n    };\n\n    const onMotionPreferenceChange = () => {\n      if (reducedMotion.matches) {\n        stop();\n        draw();\n      } else {\n        start();\n      }\n    };\n\n    redrawRef.current = () => {\n      if (!frame) draw();\n    };\n\n    const resizeObserver = new ResizeObserver(resize);\n    resizeObserver.observe(parent);\n\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) start();\n      else stop();\n    });\n    intersectionObserver.observe(canvas);\n\n    reducedMotion.addEventListener(\"change\", onMotionPreferenceChange);\n\n    return () => {\n      stop();\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      reducedMotion.removeEventListener(\"change\", onMotionPreferenceChange);\n      redrawRef.current = () => {};\n    };\n  }, [count, minRadius, maxRadius, minSpeed, maxSpeed]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      data-slot=\"floating-dots\"\n      className={cn(\"pointer-events-none absolute inset-0 size-full\", className)}\n      {...props}\n    />\n  );\n}\n"
    }
  ]
}
