{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle-field",
  "type": "registry:ui",
  "title": "Particle Field",
  "description": "Drifting particle constellation that parts around the pointer and can converge into a ring or point to signal “generating”.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/particle-field/index.ts",
      "type": "registry:ui",
      "target": "components/kura/particle-field/index.ts",
      "content": "export { ParticleField, type ParticleFieldProps } from \"./particle-field\";\n"
    },
    {
      "path": "registry/kura/particle-field/particle-field.tsx",
      "type": "registry:ui",
      "target": "components/kura/particle-field/particle-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type ParticleFieldShape = \"ring\" | \"point\";\n\nexport interface ParticleFieldProps extends React.ComponentProps<\"div\"> {\n  /** Number of particles. Default: scales with the area (about one per 5,500 px², 30–160). */\n  count?: number;\n  /** Max distance in px at which two particles are linked by a line. Default 110. */\n  linkDistance?: number;\n  /** Drift speed multiplier. Default 1. */\n  speed?: number;\n  /** Particles gently part around the pointer and link to it. Default true. */\n  interactive?: boolean;\n  /** Gather the particles into `shape` — the “generating” signal. Release to let them drift. Default false. */\n  converge?: boolean;\n  /** What the particles gather into while converging. Default \"ring\". */\n  shape?: ParticleFieldShape;\n  /** Freeze the field. Default false. */\n  paused?: boolean;\n  /** Device-pixel-ratio cap. Default 2. */\n  maxDpr?: number;\n}\n\ninterface Particle {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  seed: number;\n}\n\nfunction rand(seed: number) {\n  const s = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;\n  return s - Math.floor(s);\n}\n\n/** Resolve a CSS colour (tokens included) to a string a canvas accepts. */\nfunction cssColor(host: Element, value: string): string {\n  const probe = document.createElement(\"span\");\n  probe.style.cssText = \"position:absolute;width:0;height:0;visibility:hidden;\";\n  probe.style.color = value;\n  host.appendChild(probe);\n  const c = getComputedStyle(probe).color;\n  probe.remove();\n  return c || \"gray\";\n}\n\n/**\n * A drifting constellation: particles wander, link to their neighbours with hairlines and part\n * around the pointer. Set `converge` and they gather into a slowly turning ring (or a point) in\n * the accent colour — a calm “generating” signal. Canvas 2D, DPR-capped, paused offscreen, a\n * single still frame under reduced motion. Particles use the root's text colour.\n */\nexport function ParticleField({\n  count,\n  linkDistance = 110,\n  speed = 1,\n  interactive = true,\n  converge = false,\n  shape = \"ring\",\n  paused = false,\n  maxDpr = 2,\n  className,\n  children,\n  ref,\n  ...props\n}: ParticleFieldProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const reducedMotion = useReducedMotion();\n  const [failed, setFailed] = React.useState(false);\n  const live = React.useRef({ count, linkDistance, speed, interactive, converge, shape, paused });\n  live.current = { count, linkDistance, speed, interactive, converge, shape, paused };\n  const kick = React.useRef<() => void>(() => {});\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const root = rootRef.current;\n    if (!canvas || !root) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n      setFailed(true);\n      return;\n    }\n    setFailed(false);\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    const particles: Particle[] = [];\n    let fg = \"gray\";\n    let accent = \"teal\";\n    let amount = live.current.converge ? 1 : 0; // eased converge 0–1\n    let spin = 0;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    const pointer = { x: -1e4, y: -1e4, on: false };\n\n    const readColors = () => {\n      fg = getComputedStyle(root).color || \"gray\";\n      accent = cssColor(root, \"var(--kura-accent)\");\n    };\n    readColors();\n\n    // Density is area-based so small cards stay airy and heroes stay full.\n    const seedParticles = () => {\n      const target = live.current.count ?? Math.round(Math.min(160, Math.max(30, (w * h) / 5500)));\n      while (particles.length < target) {\n        const i = particles.length;\n        const a = rand(i + 0.1) * Math.PI * 2;\n        particles.push({\n          x: rand(i + 0.3) * w,\n          y: rand(i + 0.7) * h,\n          vx: Math.cos(a) * 0.25,\n          vy: Math.sin(a) * 0.25,\n          r: 0.8 + rand(i + 0.9) * 1.2,\n          seed: rand(i + 1.3),\n        });\n      }\n      particles.length = target;\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      dpr = Math.min(window.devicePixelRatio || 1, maxDpr);\n      w = Math.max(1, rect.width);\n      h = Math.max(1, rect.height);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      seedParticles();\n    };\n    resize();\n\n    const targetOf = (p: Particle, i: number) => {\n      const cx = w / 2;\n      const cy = h / 2;\n      const n = particles.length;\n      if (live.current.shape === \"point\") {\n        const a = p.seed * Math.PI * 2 + spin * (1.5 + p.seed);\n        const rad = 4 + (i % 9) * 2.2;\n        return { x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad };\n      }\n      const R = Math.min(w, h) * 0.24;\n      const a = (i / n) * Math.PI * 2 + spin;\n      const wob = 1 + 0.06 * Math.sin(i * 1.7 + spin * 3);\n      return { x: cx + Math.cos(a) * R * wob, y: cy + Math.sin(a) * R * wob };\n    };\n\n    const step = (dt: number) => {\n      const { speed: s, interactive: inter, converge: conv } = live.current;\n      amount += ((conv ? 1 : 0) - amount) * (1 - Math.exp(-dt * 2.2));\n      spin += dt * 0.35 * s;\n      const k = dt * 60;\n      particles.forEach((p, i) => {\n        if (amount > 0.001) {\n          const t = targetOf(p, i);\n          const pull = 0.02 * amount;\n          p.vx += (t.x - p.x) * pull * k;\n          p.vy += (t.y - p.y) * pull * k;\n          p.vx *= 1 - 0.12 * amount;\n          p.vy *= 1 - 0.12 * amount;\n        }\n        // Wander: a slow, per-particle turning drift.\n        const ang = p.seed * 20 + spin * (0.6 + p.seed);\n        p.vx += Math.cos(ang) * 0.004 * k * (1 - amount);\n        p.vy += Math.sin(ang) * 0.004 * k * (1 - amount);\n        if (inter && pointer.on) {\n          const dx = p.x - pointer.x;\n          const dy = p.y - pointer.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < 120 * 120 && d2 > 1) {\n            const f = (1 - Math.sqrt(d2) / 120) * 0.12 * (1 - amount * 0.7);\n            p.vx += (dx / Math.sqrt(d2)) * f * k;\n            p.vy += (dy / Math.sqrt(d2)) * f * k;\n          }\n        }\n        const v = Math.hypot(p.vx, p.vy);\n        const max = (0.35 + amount * 3) * s;\n        if (v > max) {\n          p.vx = (p.vx / v) * max;\n          p.vy = (p.vy / v) * max;\n        }\n        p.x += p.vx * k * s;\n        p.y += p.vy * k * s;\n        if (amount < 0.5) {\n          if (p.x < -10) p.x = w + 10;\n          if (p.x > w + 10) p.x = -10;\n          if (p.y < -10) p.y = h + 10;\n          if (p.y > h + 10) p.y = -10;\n        }\n      });\n    };\n\n    const draw = () => {\n      const link = live.current.linkDistance;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineWidth = 0.75;\n      const n = particles.length;\n      for (let pass = 0; pass < 2; pass++) {\n        const weight = pass === 0 ? 1 - amount : amount;\n        if (weight < 0.01) continue;\n        ctx.strokeStyle = pass === 0 ? fg : accent;\n        for (let i = 0; i < n; i++) {\n          const a = particles[i]!;\n          for (let j = i + 1; j < n; j++) {\n            const b = particles[j]!;\n            const dx = a.x - b.x;\n            const dy = a.y - b.y;\n            const d2 = dx * dx + dy * dy;\n            if (d2 > link * link) continue;\n            const t = 1 - Math.sqrt(d2) / link;\n            ctx.globalAlpha = t * t * 0.6 * weight;\n            ctx.beginPath();\n            ctx.moveTo(a.x, a.y);\n            ctx.lineTo(b.x, b.y);\n            ctx.stroke();\n          }\n          if (pass === 0 && live.current.interactive && pointer.on) {\n            const dx = a.x - pointer.x;\n            const dy = a.y - pointer.y;\n            const d = Math.hypot(dx, dy);\n            if (d < link * 1.4) {\n              ctx.strokeStyle = accent;\n              ctx.globalAlpha = (1 - d / (link * 1.4)) ** 2 * 0.6;\n              ctx.beginPath();\n              ctx.moveTo(a.x, a.y);\n              ctx.lineTo(pointer.x, pointer.y);\n              ctx.stroke();\n              ctx.strokeStyle = fg;\n            }\n          }\n        }\n      }\n      for (const p of particles) {\n        ctx.globalAlpha = 0.55 + 0.45 * p.seed;\n        ctx.fillStyle = amount > 0.5 ? accent : fg;\n        ctx.beginPath();\n        ctx.arc(p.x, p.y, p.r * (1 + amount * 0.3), 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const still = () => {\n      // Reduced motion: jump straight to the end state, no travel.\n      amount = live.current.converge ? 1 : 0;\n      if (amount === 1) {\n        particles.forEach((p, i) => {\n          const t = targetOf(p, i);\n          p.x = t.x;\n          p.y = t.y;\n        });\n      }\n      draw();\n    };\n\n    const frame = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      step(dt);\n      draw();\n      raf = visible && !live.current.paused ? requestAnimationFrame(frame) : 0;\n    };\n\n    const isStill = () => window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false;\n    kick.current = () => {\n      if (isStill()) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        still();\n        return;\n      }\n      if (live.current.paused) {\n        draw();\n        return;\n      }\n      if (raf || !visible) return;\n      last = 0;\n      raf = requestAnimationFrame(frame);\n    };\n    kick.current();\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (!raf) kick.current();\n    });\n    ro.observe(root);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n      if (visible) kick.current();\n    });\n    io.observe(root);\n    const mo = new MutationObserver(() => {\n      readColors();\n      if (!raf) kick.current();\n    });\n    const moOpts = { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] };\n    mo.observe(document.documentElement, moOpts);\n    mo.observe(root, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n\n    const onMove = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      pointer.x = e.clientX - r.left;\n      pointer.y = e.clientY - r.top;\n      pointer.on =\n        pointer.x >= 0 && pointer.y >= 0 && pointer.x <= r.width && pointer.y <= r.height;\n    };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      window.removeEventListener(\"pointermove\", onMove);\n    };\n  }, [maxDpr]);\n\n  // Restart or redraw when behaviour changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: kick reads the latest props from `live`.\n  React.useEffect(() => {\n    kick.current();\n  }, [paused, converge, shape, reducedMotion, count]);\n\n  const state = failed\n    ? \"fallback\"\n    : reducedMotion || paused\n      ? \"still\"\n      : converge\n        ? \"converging\"\n        : \"drifting\";\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"particle-field\"\n      data-state={state}\n      data-converge={converge}\n      className={cn(\"relative isolate overflow-hidden text-kura-fg-subtle\", className)}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        data-slot=\"particle-field-canvas\"\n        className={cn(\"-z-10 absolute inset-0 block size-full\", failed && \"hidden\")}\n      />\n      {failed && (\n        <div\n          aria-hidden\n          data-slot=\"particle-field-fallback\"\n          className=\"-z-10 absolute inset-0 bg-[radial-gradient(currentColor_1px,transparent_1.5px)] bg-size-[28px_28px] opacity-30\"\n        />\n      )}\n      {children}\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "effects",
    "particles",
    "constellation",
    "network",
    "canvas"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/particle-field · Markdown for agents: https://kuro.abstergo.fyi/components/particle-field.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "An empty state or hero for a knowledge/graph/agent product where a quiet network motif fits.",
        "Signalling that something is being generated: flip `converge` while the model works, release when done.",
        "A subtle, interactive backdrop behind a short prompt or call to action."
      ],
      "whenNotToUse": [
        "Soft colour backgrounds — use mesh-gradient or aurora.",
        "Inline progress feedback — use thinking-indicator or shimmer-text.",
        "A voice agent's presence — use agent-orb.",
        "Behind dense content: lines and motion compete with text."
      ]
    }
  }
}
