{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbiting-skills",
  "title": "Orbiting Skills",
  "description": "Animated skill badges that orbit a center element. On desktop the ring follows the cursor on hover; on mobile the ring is always visible.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/components/unlumen/orbiting-skills/index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useMotionValue,\n  useSpring,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type OrbitSkillItem = {\n  /** Short label displayed under the icon */\n  label: string;\n  /** Icon element (e.g. an <svg> or <img>) */\n  icon?: React.ReactNode;\n};\n\nexport interface OrbitingSkillsProps {\n  /** Skill items to orbit around the center element */\n  items: OrbitSkillItem[];\n  /**\n   * Orbit radius in pixels.\n   * @default 88\n   */\n  radius?: number;\n  /**\n   * Duration of one full orbit rotation, in seconds.\n   * @default 18\n   */\n  duration?: number;\n  /** Whether to render the circular orbit path */\n  showPath?: boolean;\n  /**\n   * On desktop the orbit appears only on hover and follows the cursor.\n   * Set to `false` to keep it centered at all times.\n   * @default true\n   */\n  followCursor?: boolean;\n  /** Center content (avatar, logo, …) */\n  children?: React.ReactNode;\n  className?: string;\n}\n\nfunction useIsMobile() {\n  const [mobile, setMobile] = React.useState(false);\n  React.useEffect(() => {\n    const mq = window.matchMedia(\"(max-width: 767px)\");\n    setMobile(mq.matches);\n    const handler = (e: MediaQueryListEvent) => setMobile(e.matches);\n    mq.addEventListener(\"change\", handler);\n    return () => mq.removeEventListener(\"change\", handler);\n  }, []);\n  return mobile;\n}\n\ntype BadgeProps = {\n  item: OrbitSkillItem;\n  index: number;\n  total: number;\n  radius: number;\n  duration: number;\n  size?: \"sm\" | \"md\";\n};\n\nfunction OrbitBadge({\n  item,\n  index,\n  total,\n  radius,\n  duration,\n  size = \"md\",\n}: BadgeProps) {\n  const startAngle = (360 / total) * index;\n  const isSm = size === \"sm\";\n\n  return (\n    <motion.div\n      className=\"absolute\"\n      style={{ width: 0, height: 0 }}\n      initial={{ rotate: startAngle }}\n      animate={{ rotate: startAngle + 360 }}\n      transition={{ duration, ease: \"linear\", repeat: Infinity }}\n    >\n      <motion.div\n        className=\"absolute\"\n        style={{ y: -radius }}\n        initial={{ scale: 0, opacity: 0 }}\n        animate={{ scale: 1, opacity: 1 }}\n        transition={{\n          delay: index * 0.1,\n          duration: 0.5,\n          type: \"spring\",\n          bounce: 0.45,\n        }}\n      >\n        {/* counter-rotate so the badge stays upright */}\n        <motion.div\n          className={cn(\n            \"flex flex-col items-center gap-0.5 whitespace-nowrap rounded-xl border border-border bg-background shadow-sm\",\n            isSm ? \"px-2 py-1\" : \"px-3 py-2\",\n          )}\n          style={{ x: \"-50%\", y: \"-50%\" }}\n          initial={{ rotate: -startAngle }}\n          animate={{ rotate: -startAngle - 360 }}\n          transition={{ duration, ease: \"linear\", repeat: Infinity }}\n        >\n          {item.icon && (\n            <span\n              className={cn(\"leading-none\", isSm ? \"text-sm\" : \"text-base\")}\n            >\n              {item.icon}\n            </span>\n          )}\n          <span\n            className={cn(\n              \"font-medium leading-none\",\n              isSm ? \"text-[8px]\" : \"text-[10px]\",\n            )}\n          >\n            {item.label}\n          </span>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n  );\n}\n\ntype RingProps = { radius: number; cx?: number; cy?: number };\n\nfunction OrbitRing({ radius, cx, cy }: RingProps) {\n  return (\n    <svg\n      className=\"pointer-events-none absolute\"\n      style={{\n        width: radius * 2,\n        height: radius * 2,\n        left: cx !== undefined ? cx - radius : 0,\n        top: cy !== undefined ? cy - radius : 0,\n      }}\n    >\n      <circle\n        className=\"stroke-foreground/8 stroke-1\"\n        cx={radius}\n        cy={radius}\n        r={radius - 0.5}\n        fill=\"none\"\n      />\n    </svg>\n  );\n}\n\nexport function OrbitingSkills({\n  items,\n  radius = 88,\n  duration = 18,\n  showPath = true,\n  followCursor = true,\n  children,\n  className,\n}: OrbitingSkillsProps) {\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const centerRef = React.useRef<HTMLDivElement>(null);\n  const isInView = useInView(wrapperRef, {\n    once: true,\n    margin: \"0px 0px -60px 0px\",\n  });\n  const isMobile = useIsMobile();\n\n  const rawX = useMotionValue(0);\n  const rawY = useMotionValue(0);\n  const x = useSpring(rawX, { stiffness: 200, damping: 18 });\n  const y = useSpring(rawY, { stiffness: 200, damping: 18 });\n\n  const [hovered, setHovered] = React.useState(false);\n\n  const handleMouseMove = React.useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      if (!wrapperRef.current) return;\n      const rect = wrapperRef.current.getBoundingClientRect();\n      rawX.set(e.clientX - rect.left);\n      rawY.set(e.clientY - rect.top);\n    },\n    [rawX, rawY],\n  );\n\n  // Default cursor position to center of wrapper on mount (prevents flash)\n  React.useEffect(() => {\n    if (!wrapperRef.current) return;\n    const { width, height } = wrapperRef.current.getBoundingClientRect();\n    rawX.set(width / 2);\n    rawY.set(height / 2);\n  }, [rawX, rawY]);\n\n  const showDesktopOrbit =\n    !isMobile && followCursor ? hovered : !isMobile && !followCursor;\n  const mobileRadius = Math.round(radius * 0.78);\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={cn(\"relative\", className)}\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n      onMouseMove={handleMouseMove}\n    >\n      <div ref={centerRef}>{children}</div>\n\n      {isMobile && children && (\n        <div\n          className=\"pointer-events-none absolute inset-0\"\n          aria-hidden=\"true\"\n        >\n          <MobileCenterOrbit\n            items={items}\n            radius={mobileRadius}\n            duration={duration}\n            showPath={showPath}\n            isInView={isInView}\n            centerRef={centerRef}\n          />\n        </div>\n      )}\n\n      <AnimatePresence>\n        {showDesktopOrbit && (\n          <motion.div\n            className=\"pointer-events-none absolute inset-0 z-10 overflow-visible\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.25 }}\n            aria-hidden=\"true\"\n          >\n            <motion.div className=\"absolute\" style={{ left: x, top: y }}>\n              {showPath && <OrbitRing radius={radius} cx={0} cy={0} />}\n              {items.map((item, i) => (\n                <OrbitBadge\n                  key={item.label}\n                  item={item}\n                  index={i}\n                  total={items.length}\n                  radius={radius}\n                  duration={duration}\n                />\n              ))}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {!isMobile && !followCursor && (\n        <div\n          className=\"pointer-events-none absolute inset-0 overflow-visible\"\n          aria-hidden=\"true\"\n        >\n          <DesktopCenteredOrbit\n            items={items}\n            radius={radius}\n            duration={duration}\n            showPath={showPath}\n            centerRef={centerRef}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\ntype SubOrbitProps = {\n  items: OrbitSkillItem[];\n  radius: number;\n  duration: number;\n  showPath: boolean;\n  centerRef: React.RefObject<HTMLDivElement | null>;\n};\n\nfunction DesktopCenteredOrbit({\n  items,\n  radius,\n  duration,\n  showPath,\n  centerRef,\n}: SubOrbitProps) {\n  const [center, setCenter] = React.useState<{ x: number; y: number } | null>(\n    null,\n  );\n\n  React.useLayoutEffect(() => {\n    if (!centerRef.current) return;\n    const el = centerRef.current;\n    const parent = el.closest(\"[data-orbit-root]\") ?? el.parentElement;\n    if (!parent) return;\n    const pr = parent.getBoundingClientRect();\n    const cr = el.getBoundingClientRect();\n    setCenter({\n      x: cr.left - pr.left + cr.width / 2,\n      y: cr.top - pr.top + cr.height / 2,\n    });\n  }, [centerRef]);\n\n  if (!center) return null;\n\n  return (\n    <div className=\"absolute\" style={{ left: center.x, top: center.y }}>\n      {showPath && <OrbitRing radius={radius} cx={0} cy={0} />}\n      {items.map((item, i) => (\n        <OrbitBadge\n          key={item.label}\n          item={item}\n          index={i}\n          total={items.length}\n          radius={radius}\n          duration={duration}\n        />\n      ))}\n    </div>\n  );\n}\n\ntype MobileSubOrbitProps = SubOrbitProps & { isInView: boolean };\n\nfunction MobileCenterOrbit({\n  items,\n  radius,\n  duration,\n  showPath,\n  isInView,\n  centerRef,\n}: MobileSubOrbitProps) {\n  const [center, setCenter] = React.useState<{ x: number; y: number } | null>(\n    null,\n  );\n\n  React.useLayoutEffect(() => {\n    if (!centerRef.current) return;\n    const el = centerRef.current;\n    const parent = el.closest(\"[data-orbit-root]\") ?? el.parentElement;\n    if (!parent) return;\n    const pr = parent.getBoundingClientRect();\n    const cr = el.getBoundingClientRect();\n    setCenter({\n      x: cr.left - pr.left + cr.width / 2,\n      y: cr.top - pr.top + cr.height / 2,\n    });\n  }, [centerRef]);\n\n  if (!center) return null;\n\n  return (\n    <div className=\"absolute\" style={{ left: center.x, top: center.y }}>\n      {showPath && (\n        <motion.svg\n          className=\"pointer-events-none absolute\"\n          style={{\n            width: radius * 2,\n            height: radius * 2,\n            left: -radius,\n            top: -radius,\n          }}\n          initial={{ scale: 0.5, opacity: 0 }}\n          animate={\n            isInView ? { scale: 1, opacity: 1 } : { scale: 0.5, opacity: 0 }\n          }\n          transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}\n        >\n          <circle\n            className=\"stroke-foreground/5 stroke-1\"\n            cx={radius}\n            cy={radius}\n            r={radius - 0.5}\n            fill=\"none\"\n          />\n        </motion.svg>\n      )}\n      {isInView &&\n        items.map((item, i) => (\n          <OrbitBadge\n            key={item.label}\n            item={item}\n            index={i}\n            total={items.length}\n            radius={radius}\n            duration={duration}\n            size=\"sm\"\n          />\n        ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/orbiting-skills.tsx"
    }
  ],
  "type": "registry:ui"
}