{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-cloud",
  "title": "Icon Cloud",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Icons, logos or labels orbiting on a 3D sphere, spun by dragging, calming under the pointer and when anything has focus, with real list markup so every item stays reachable.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/icon-cloud.tsx",
      "content": "// Ballmac UI: Icon Cloud. https://ui.ballmac.com/components/icon-cloud\n\"use client\"\n\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype IconCloudProps = Omit<React.ComponentProps<\"ul\">, \"children\"> & {\n  /** The items on the sphere: icons, logos, short labels, links or buttons. Each one is a list item. */\n  children: React.ReactNode\n  /** Diameter of the sphere in pixels. */\n  size?: number\n  /** Turns per minute when left alone. */\n  speed?: number\n  /** Accessible name of the list. */\n  label?: string\n}\n\ntype Vec = { x: number; y: number; z: number }\n\n/** Spreads n points evenly over a sphere (the Fibonacci spiral). */\nfunction spherePoints(n: number): Vec[] {\n  if (n === 1) return [{ x: 0, y: 0, z: 1 }]\n  const golden = Math.PI * (3 - Math.sqrt(5))\n  return Array.from({ length: n }, (_, i) => {\n    const y = 1 - (i / (n - 1)) * 2\n    const r = Math.sqrt(Math.max(0, 1 - y * y))\n    const theta = golden * i\n    return { x: Math.cos(theta) * r, y, z: Math.sin(theta) * r }\n  })\n}\n\n/**\n * Items orbiting on a 3D sphere. Drag to spin it; it eases to a stop under the pointer and while any item has\n * focus, so links stay clickable. Updates go straight to the DOM, so React does not re-render each frame.\n */\nfunction IconCloud({ children, size = 320, speed = 1.2, label, className, style, ...props }: IconCloudProps) {\n  const msg = useMessages()\n  label ??= msg(\"icon-cloud.label\", \"Items\")\n  const reduce = useReducedMotion()\n  const items = React.Children.toArray(children)\n  const refs = React.useRef<(HTMLLIElement | null)[]>([])\n  const rootRef = React.useRef<HTMLUListElement>(null)\n  const points = React.useMemo(() => spherePoints(items.length), [items.length])\n  const state = React.useRef({ ax: 0.3, ay: 0, vx: 0, vy: 0, paused: false, down: false, dragging: false, last: { x: 0, y: 0 } })\n\n  const render = React.useCallback(() => {\n    const { ax, ay } = state.current\n    const R = Math.max(40, Math.min(size, (rootRef.current?.clientWidth ?? size + 64) - 64) / 2)\n    const cosX = Math.cos(ax)\n    const sinX = Math.sin(ax)\n    const cosY = Math.cos(ay)\n    const sinY = Math.sin(ay)\n    points.forEach((p, i) => {\n      const el = refs.current[i]\n      if (!el) return\n      // Spin around the vertical axis, then tilt around the horizontal one.\n      const x1 = p.x * cosY + p.z * sinY\n      const z1 = -p.x * sinY + p.z * cosY\n      const y2 = p.y * cosX - z1 * sinX\n      const z2 = p.y * sinX + z1 * cosX\n      const depth = (z2 + 1) / 2\n      const scale = 0.55 + depth * 0.65\n      el.style.transform = `translate3d(${(x1 * R).toFixed(1)}px, ${(y2 * R).toFixed(1)}px, 0) translate(-50%, -50%) scale(${scale.toFixed(3)})`\n      el.style.opacity = String((0.62 + depth * 0.38).toFixed(3))\n      el.style.zIndex = String(Math.round(depth * 100))\n      el.style.filter = depth < 0.35 ? \"blur(0.6px)\" : \"\"\n    })\n  }, [points, size])\n\n  React.useEffect(() => {\n    render()\n    if (reduce) {\n      const still = new ResizeObserver(() => render())\n      if (rootRef.current) still.observe(rootRef.current)\n      return () => still.disconnect()\n    }\n    let frame = 0\n    let last = 0\n    let visible = true\n    const loop = (now: number) => {\n      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0\n      last = now\n      const s = state.current\n      if (!s.dragging) {\n        const idle = s.paused ? 0 : (speed * Math.PI * 2) / 60\n        s.vy += (idle - s.vy) * Math.min(1, dt * 3)\n        s.vx += (0 - s.vx) * Math.min(1, dt * 3)\n        s.ay += s.vy * dt\n        s.ax += s.vx * dt\n      }\n      render()\n      frame = requestAnimationFrame(loop)\n    }\n    const ro = new ResizeObserver(() => render())\n    if (rootRef.current) ro.observe(rootRef.current)\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true\n      cancelAnimationFrame(frame)\n      last = 0\n      if (visible) frame = requestAnimationFrame(loop)\n    })\n    if (rootRef.current) io.observe(rootRef.current)\n    frame = requestAnimationFrame(loop)\n    return () => {\n      cancelAnimationFrame(frame)\n      io.disconnect()\n      ro.disconnect()\n    }\n  }, [render, reduce, speed])\n\n  return (\n    <ul\n      ref={rootRef}\n      data-slot=\"icon-cloud\"\n      aria-label={label}\n      className={cn(\"relative m-0 list-none touch-pan-y p-0 select-none\", !reduce && \"cursor-grab active:cursor-grabbing\", className)}\n      style={{ width: size + 64, maxWidth: \"100%\", aspectRatio: \"1 / 1\", ...style }}\n      onPointerDown={(e) => {\n        if (reduce) return\n        state.current.down = true\n        state.current.last = { x: e.clientX, y: e.clientY }\n      }}\n      onPointerMove={(e) => {\n        const s = state.current\n        if (!s.down) return\n        const dx = e.clientX - s.last.x\n        const dy = e.clientY - s.last.y\n        // Wait for a real drag before capturing the pointer, so a plain click still reaches the item under it.\n        if (!s.dragging) {\n          if (Math.hypot(dx, dy) < 5) return\n          s.dragging = true\n          ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)\n        }\n        s.last = { x: e.clientX, y: e.clientY }\n        s.ay += dx * 0.008\n        s.ax -= dy * 0.008\n        s.vy = dx * 0.25\n        s.vx = -dy * 0.25\n      }}\n      onPointerUp={() => {\n        state.current.down = false\n        state.current.dragging = false\n      }}\n      onPointerCancel={() => {\n        state.current.down = false\n        state.current.dragging = false\n      }}\n      onPointerEnter={() => {\n        state.current.paused = true\n      }}\n      onPointerLeave={() => {\n        state.current.paused = false\n        state.current.down = false\n        state.current.dragging = false\n      }}\n      onFocusCapture={() => {\n        state.current.paused = true\n      }}\n      onBlurCapture={() => {\n        state.current.paused = false\n      }}\n      {...props}\n    >\n      {items.map((child, i) => (\n        <li\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el\n          }}\n          className=\"absolute top-1/2 left-1/2 flex items-center justify-center will-change-transform\"\n        >\n          {child}\n        </li>\n      ))}\n    </ul>\n  )\n}\n\nexport { IconCloud, type IconCloudProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/icon-cloud.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "sphere",
      "3d",
      "logos",
      "cloud",
      "tech stack"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<IconCloud size speed label>{items}</IconCloud>. Each child becomes a list item. Positions come from a Fibonacci sphere and update straight on the DOM. Links inside stay clickable because dragging only starts after a few pixels of movement.",
      "whenToUse": [
        "Showing a technology or partner ecosystem",
        "A playful, interactive hero centerpiece"
      ],
      "whenNotToUse": [
        "Anything people must read quickly",
        "Long lists (use a grid)"
      ],
      "composesWith": [
        "orbiting-circles",
        "globe",
        "marquee"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Items remain focusable in document order; rotation stops while one has focus"
        },
        {
          "keys": "Screen readers",
          "action": "A named list; position changes are visual only"
        },
        {
          "keys": "Reduced motion",
          "action": "A still sphere"
        }
      ],
      "customization": [
        "size",
        "speed",
        "label",
        "children as icons, links or text"
      ]
    },
    "examples": [
      "icon-cloud-demo",
      "icon-cloud-links"
    ],
    "url": "https://ui.ballmac.com/components/icon-cloud"
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}