{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "connection-beams",
  "type": "registry:ui",
  "title": "Connection Beams",
  "description": "Curved SVG beams between nodes with a travelling comet of light — for integration diagrams.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/connection-beams.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface BeamLink {\n  /** `name` of the node where the beam starts. */\n  from: string;\n  /** `name` of the node where the beam ends. */\n  to: string;\n  /** Flip this beam's travel direction. */\n  reverse?: boolean;\n}\n\nexport interface ConnectionBeamsProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Explicit connections. When omitted, every node is connected to the node marked `hub`. */\n  beams?: BeamLink[];\n  /** Sideways bend of each beam, in px (negative bends the other way). */\n  curvature?: number;\n  /** Time for a light pulse to travel one beam, in ms. */\n  duration?: number;\n  /** Delay between beams, in ms. */\n  stagger?: number;\n  /** Send the light from the hub outward (or from `to` back to `from`). */\n  reverse?: boolean;\n  /** Length of the travelling light as a fraction of the beam (0.05–0.6). */\n  beamLength?: number;\n  /** Stroke width of the beams, in px. */\n  pathWidth?: number;\n  /** Colour at the start of each beam. */\n  fromColor?: string;\n  /** Colour at the end of each beam. */\n  toColor?: string;\n  /** Render a screen-reader list of the connections (uses each node's `label`). */\n  describe?: boolean;\n}\n\nexport interface BeamNodeProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Unique key used by `beams` (not rendered as a DOM id). */\n  name: string;\n  /** Marks the hub that every other node connects to by default. */\n  hub?: boolean;\n  /** Human-readable name used in the screen-reader description of the connections. */\n  label?: string;\n}\n\ninterface NodeEntry {\n  el: HTMLElement;\n  hub: boolean;\n  label?: string;\n}\n\ninterface BeamPath {\n  key: string;\n  d: string;\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n  fromLabel: string;\n  toLabel: string;\n}\n\ninterface BeamsContextValue {\n  register: (name: string, entry: NodeEntry) => () => void;\n}\n\nconst BeamsContext = React.createContext<BeamsContextValue | null>(null);\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Geometry --------------------------------- */\n\nconst round = (n: number) => Math.round(n * 10) / 10;\n\n/** Quadratic curve between two points, bent sideways by `curvature` px. */\nfunction curve(x1: number, y1: number, x2: number, y2: number, curvature: number) {\n  const dx = x2 - x1;\n  const dy = y2 - y1;\n  const len = Math.hypot(dx, dy) || 1;\n  const cx = (x1 + x2) / 2 + (-dy / len) * curvature;\n  const cy = (y1 + y2) / 2 + (dx / len) * curvature;\n  return `M ${round(x1)} ${round(y1)} Q ${round(cx)} ${round(cy)} ${round(x2)} ${round(y2)}`;\n}\n\n/** Comet layers: tail → head. Each is a dash of a fraction of the full length. */\nconst LAYERS = [\n  { length: 1, opacity: 0.22, width: 1 },\n  { length: 0.55, opacity: 0.5, width: 1 },\n  { length: 0.18, opacity: 1, width: 1.35 },\n];\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * Animated SVG beams between `BeamNode`s inside a container. Node centres are measured with\n * ResizeObserver, joined by gently curved quadratic paths, and a comet of light travels each\n * path by animating `stroke-dashoffset` on a normalised `pathLength` (Web Animations API), so\n * pulses take the same time on short and long beams. Animations pause offscreen and in hidden\n * tabs; reduced motion draws static gradient lines.\n */\nexport function ConnectionBeams({\n  beams,\n  curvature = 40,\n  duration = 2800,\n  stagger = 450,\n  reverse = false,\n  beamLength = 0.3,\n  pathWidth = 1.5,\n  fromColor = \"#FF7448\",\n  toColor = \"#B6F3D1\",\n  describe = true,\n  className = \"\",\n  children,\n  ...props\n}: ConnectionBeamsProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId().replace(/:/g, \"\");\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const svgRef = React.useRef<SVGSVGElement>(null);\n  const nodesRef = React.useRef(new Map<string, NodeEntry>());\n  const hooksRef = React.useRef<{ schedule: () => void; observe: (el: Element) => void; unobserve: (el: Element) => void } | null>(null);\n  const [paths, setPaths] = React.useState<BeamPath[]>([]);\n\n  const register = React.useCallback((name: string, entry: NodeEntry) => {\n    nodesRef.current.set(name, entry);\n    hooksRef.current?.observe(entry.el);\n    hooksRef.current?.schedule();\n    return () => {\n      if (nodesRef.current.get(name)?.el === entry.el) nodesRef.current.delete(name);\n      hooksRef.current?.unobserve(entry.el);\n      hooksRef.current?.schedule();\n    };\n  }, []);\n  const context = React.useMemo(() => ({ register }), [register]);\n\n  // Serialised so an inline `beams` array does not re-run the effect on every render.\n  const beamsKey = beams ? JSON.stringify(beams) : \"\";\n\n  React.useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    const links: BeamLink[] | null = beamsKey ? (JSON.parse(beamsKey) as BeamLink[]) : null;\n    let raf = 0;\n    let lastSignature = \"\";\n\n    const measure = () => {\n      raf = 0;\n      const box = container.getBoundingClientRect();\n      const nodes = nodesRef.current;\n      const centre = (el: HTMLElement) => {\n        const r = el.getBoundingClientRect();\n        return [r.left + r.width / 2 - box.left, r.top + r.height / 2 - box.top] as const;\n      };\n      let list = links;\n      if (!list) {\n        const hub = [...nodes.entries()].find(([, n]) => n.hub)?.[0];\n        list = hub ? [...nodes.keys()].filter((k) => k !== hub).map((k) => ({ from: k, to: hub })) : [];\n      }\n      const next: BeamPath[] = [];\n      for (const link of list) {\n        const a = nodes.get(link.from);\n        const b = nodes.get(link.to);\n        if (!a || !b) continue;\n        const flip = reverse !== Boolean(link.reverse);\n        const [ax, ay] = centre(flip ? b.el : a.el);\n        const [bx, by] = centre(flip ? a.el : b.el);\n        next.push({\n          key: `${link.from}->${link.to}`,\n          d: curve(ax, ay, bx, by, flip ? -curvature : curvature),\n          x1: round(ax),\n          y1: round(ay),\n          x2: round(bx),\n          y2: round(by),\n          fromLabel: a.label ?? link.from,\n          toLabel: b.label ?? link.to,\n        });\n      }\n      const signature = next.map((p) => p.key + p.d + p.fromLabel + p.toLabel).join(\"|\");\n      if (signature !== lastSignature) {\n        lastSignature = signature;\n        setPaths(next);\n      }\n    };\n    const schedule = () => {\n      if (!raf) raf = requestAnimationFrame(measure);\n    };\n\n    const ro = new ResizeObserver(schedule);\n    ro.observe(container);\n    for (const n of nodesRef.current.values()) ro.observe(n.el);\n    hooksRef.current = { schedule, observe: (el) => ro.observe(el), unobserve: (el) => ro.unobserve(el) };\n    schedule();\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      hooksRef.current = null;\n    };\n  }, [beamsKey, curvature, reverse]);\n\n  // Travelling light: one infinite animation per comet layer, re-created when beams change.\n  const pathsKey = paths.map((p) => p.key).join(\"|\");\n  React.useEffect(() => {\n    const svg = svgRef.current;\n    if (!svg || reduceMotion || !pathsKey) return;\n    const L = Math.min(0.6, Math.max(0.05, beamLength));\n    const animations: Animation[] = [];\n    svg.querySelectorAll<SVGPathElement>(\"[data-beam-layer]\").forEach((el) => {\n      const index = Number(el.dataset.beamIndex);\n      const len = L * Number(el.dataset.beamLayer);\n      // Head travels from 0 to 1 + L so the whole tail leaves the path before repeating.\n      el.style.strokeDasharray = `${len} ${1 + L}`;\n      animations.push(\n        el.animate([{ strokeDashoffset: `${len}` }, { strokeDashoffset: `${len - 1 - L}` }], {\n          duration: Math.max(200, duration),\n          delay: index * Math.max(0, stagger),\n          iterations: Infinity,\n          easing: \"cubic-bezier(0.45, 0.05, 0.35, 1)\",\n          fill: \"backwards\",\n        }),\n      );\n    });\n\n    let inView = true;\n    const sync = () => {\n      const run = inView && !document.hidden;\n      for (const a of animations) {\n        if (run) a.play();\n        else a.pause();\n      }\n    };\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      sync();\n    });\n    io.observe(svg);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      for (const a of animations) a.cancel();\n    };\n  }, [pathsKey, duration, stagger, beamLength, reduceMotion]);\n\n  return (\n    <BeamsContext.Provider value={context}>\n      <div ref={containerRef} className={`relative ${className}`} {...props}>\n        <svg\n          ref={svgRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\"\n          fill=\"none\"\n        >\n          <defs>\n            {paths.map((p, i) => (\n              <linearGradient key={p.key} id={`${uid}-g${i}`} gradientUnits=\"userSpaceOnUse\" x1={p.x1} y1={p.y1} x2={p.x2} y2={p.y2}>\n                <stop offset=\"0\" stopColor={fromColor} />\n                <stop offset=\"1\" stopColor={toColor} />\n              </linearGradient>\n            ))}\n          </defs>\n          {paths.map((p, i) => (\n            <g key={p.key}>\n              <path\n                d={p.d}\n                strokeWidth={pathWidth}\n                strokeLinecap=\"round\"\n                className=\"stroke-zinc-900/10 dark:stroke-white/10\"\n              />\n              {reduceMotion ? (\n                <path d={p.d} stroke={`url(#${uid}-g${i})`} strokeWidth={pathWidth} strokeLinecap=\"round\" opacity={0.55} />\n              ) : (\n                LAYERS.map((layer) => (\n                  <path\n                    key={layer.length}\n                    data-beam-layer={layer.length}\n                    data-beam-index={i}\n                    d={p.d}\n                    pathLength={1}\n                    stroke={`url(#${uid}-g${i})`}\n                    strokeWidth={pathWidth * layer.width}\n                    strokeLinecap=\"round\"\n                    strokeDasharray=\"0 2\"\n                    opacity={layer.opacity}\n                  />\n                ))\n              )}\n            </g>\n          ))}\n        </svg>\n        {children}\n        {describe && paths.length > 0 && (\n          <ul className=\"sr-only\">\n            {paths.map((p) => (\n              <li key={p.key}>{`${p.fromLabel} connects to ${p.toLabel}`}</li>\n            ))}\n          </ul>\n        )}\n      </div>\n    </BeamsContext.Provider>\n  );\n}\n\n/** A measured endpoint for beams. Give it a background so beams appear to emerge from its edge. */\nexport function BeamNode({ name, hub = false, label, className = \"\", children, ...props }: BeamNodeProps) {\n  const ctx = React.useContext(BeamsContext);\n  const setRef = React.useCallback(\n    (el: HTMLDivElement | null) => {\n      if (!el || !ctx) return;\n      return ctx.register(name, { el, hub, label });\n    },\n    [ctx, name, hub, label],\n  );\n  return (\n    <div\n      ref={setRef}\n      data-hub={hub || undefined}\n      className={`relative z-10 flex items-center justify-center rounded-2xl border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 ${className}`}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/connection-beams"
}
