{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button-group",
  "type": "registry:ui",
  "title": "Button Group",
  "description": "Attached buttons with hairlines between and one hover wash that glides.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/button-group.tsx",
      "content": "\"use client\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Children, Fragment, isValidElement, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\n\nexport type ButtonGroupProps = Omit<React.ComponentProps<\"div\">, \"role\"> & {\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Names the group for screen readers (\"Zoom\", \"Page navigation\"). */\n  \"aria-label\"?: string;\n};\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Glide = { rect: Rect; shown: boolean; instant: boolean; pressed: boolean };\n\n// How far the hover wash sits inside each segment, in px.\nconst INSET = 3;\n\n/**\n * Buttons that read as one object: one border, one shadow, hairlines between,\n * and a single hover wash that glides from segment to segment instead of each\n * one lighting up on its own. Button and IconButton flatten themselves inside it.\n */\nexport function ButtonGroup({ orientation = \"horizontal\", className, children, onPointerLeave, ...rest }: ButtonGroupProps) {\n  const reduce = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const [glide, setGlide] = useState<Glide>({ rect: { x: 0, y: 0, w: 0, h: 0 }, shown: false, instant: true, pressed: false });\n  const vertical = orientation === \"vertical\";\n  const items = Children.toArray(children).filter(isValidElement);\n\n  const segmentFor = (target: EventTarget | null) => {\n    const root = ref.current;\n    if (!root || !(target instanceof Element)) return null;\n    for (const el of Array.from(root.children) as HTMLElement[]) {\n      if (el.dataset.groupPart) continue;\n      if (!el.contains(target)) continue;\n      if (el.matches(\":disabled, [aria-disabled='true'], [data-disabled]\")) return null;\n      return el;\n    }\n    return null;\n  };\n  const rectOf = (el: HTMLElement): Rect => ({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });\n\n  // An open menu keeps the wash under its trigger until it closes.\n  const openTrigger = () => (Array.from(ref.current?.children ?? []) as HTMLElement[]).find((el) => el.matches(\"[aria-expanded='true']\"));\n  const settle = () => {\n    const open = openTrigger();\n    setGlide((g) => (open ? { ...g, rect: rectOf(open), shown: true, pressed: false } : { ...g, shown: false, pressed: false }));\n  };\n\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    const observer = new MutationObserver(() => {\n      if (root.matches(\":hover\")) return;\n      const open = (Array.from(root.children) as HTMLElement[]).find((el) => el.matches(\"[aria-expanded='true']\"));\n      setGlide((g) => (open ? { ...g, rect: rectOf(open), shown: true } : { ...g, shown: false, pressed: false }));\n    });\n    observer.observe(root, { attributes: true, subtree: true, attributeFilter: [\"aria-expanded\"] });\n    return () => observer.disconnect();\n  }, []);\n\n  const move = (e: React.PointerEvent, pressed?: boolean) => {\n    // Events from a portaled menu bubble here through React; to the eye the pointer has left.\n    if (!(e.target instanceof Node) || !ref.current?.contains(e.target)) return settle();\n    if (e.pointerType !== \"mouse\" && pressed === undefined) return;\n    const el = segmentFor(e.target);\n    if (!el) return setGlide((g) => ({ ...g, shown: false, pressed: false }));\n    const rect = rectOf(el);\n    setGlide((g) => ({ rect, shown: true, instant: !g.shown, pressed: pressed ?? g.pressed }));\n  };\n\n  const t = glide.instant || reduce ? { duration: 0 } : spring.follow;\n\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      data-slot=\"button-group\"\n      data-orientation={orientation}\n      onPointerMove={(e) => move(e)}\n      onPointerDown={(e) => move(e, true)}\n      onPointerUp={(e) => (e.pointerType === \"mouse\" ? move(e, false) : setGlide((g) => ({ ...g, shown: false, pressed: false })))}\n      onPointerCancel={() => setGlide((g) => ({ ...g, shown: false, pressed: false }))}\n      onPointerLeave={(e) => {\n        onPointerLeave?.(e);\n        settle();\n      }}\n      className={cn(\n        \"group/bg relative isolate inline-flex shrink-0 rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)]\",\n        \"has-[[data-size=sm]]:rounded-md\",\n        vertical ? \"flex-col\" : \"flex-row items-stretch\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden\n        data-group-part=\"glide\"\n        className=\"pointer-events-none absolute left-0 top-0 -z-10 rounded-[5px] bg-fg/[0.07] group-has-[[data-size=sm]]/bg:rounded-[3px]\"\n        initial={false}\n        animate={{\n          x: glide.rect.x + INSET,\n          y: glide.rect.y + INSET,\n          width: Math.max(0, glide.rect.w - INSET * 2),\n          height: Math.max(0, glide.rect.h - INSET * 2),\n          opacity: glide.shown ? 1 : 0,\n          scale: glide.pressed && !reduce ? 0.94 : 1,\n        }}\n        transition={{\n          x: t,\n          y: t,\n          width: t,\n          height: t,\n          opacity: { duration: glide.shown ? 0.12 : 0.15 },\n          scale: glide.pressed ? { duration: 0.08 } : spring.snappy,\n        }}\n      />\n      {items.map((child, i) => (\n        <Fragment key={child.key ?? i}>\n          {i > 0 && (\n            <span\n              aria-hidden\n              data-group-part=\"separator\"\n              className={cn(\"shrink-0 bg-line-2\", vertical ? \"h-px w-full\" : \"my-0 w-px self-stretch\")}\n            />\n          )}\n          {child}\n        </Fragment>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/button-group"
}
