{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button-group",
  "type": "registry:ui",
  "title": "Button group",
  "description": "Related actions joined into one surface with hairline dividers: a hover highlight glides between segments, the pressed one answers in place, and an attached menu can close the row.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/button-group/button-group.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode, RefObject } from \"react\";\nimport * as DropdownPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type ButtonGroupVariant = \"outline\" | \"solid\";\nexport type ButtonGroupSize = \"sm\" | \"md\";\nexport type ButtonGroupOrientation = \"horizontal\" | \"vertical\";\n\nexport interface ButtonGroupItem {\n  /** Stable key for the segment. */\n  id: string;\n  /** Visible text and the accessible name. Changing it crossfades in place, so \"Share\" can answer \"Copied\". */\n  label: string;\n  /** Every other label this segment can show, such as [\"Copied\"]. The segment sizes to the widest, so a label change never resizes it. */\n  reserve?: string[];\n  icon?: ReactNode;\n  /** Shows only the icon; the label becomes the accessible name and the tooltip. */\n  iconOnly?: boolean;\n  /** Visible content in place of the label, such as a live value. The label stays the accessible name. */\n  content?: ReactNode;\n  onSelect?: () => void;\n  /** Renders the segment as a link. */\n  href?: string;\n  disabled?: boolean;\n}\n\nexport interface ButtonGroupMenuItem {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  onSelect?: () => void;\n  href?: string;\n  disabled?: boolean;\n  /** Colors the item as a destructive action. */\n  destructive?: boolean;\n}\n\nexport interface ButtonGroupMenu {\n  /** Accessible name and tooltip of the chevron segment, such as \"More actions\". */\n  label: string;\n  items: ButtonGroupMenuItem[];\n}\n\nexport interface ButtonGroupProps {\n  items: ButtonGroupItem[];\n  /** Adds a trailing chevron segment that opens these actions in a menu aligned to the group's edge. */\n  menu?: ButtonGroupMenu;\n  /** Accessible name of the group, such as \"Document actions\". */\n  label: string;\n  variant?: ButtonGroupVariant;\n  size?: ButtonGroupSize;\n  orientation?: ButtonGroupOrientation;\n  /** When the row does not fit its container, segments with an icon drop their label and keep the icon. Horizontal only. */\n  collapseLabels?: boolean;\n  /** Disables every segment and the menu. */\n  disabled?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/** Key of the chevron segment. Plain text, because the HTML parser rewrites control characters in server-rendered attributes. */\nconst MENU = \"button-group-menu\";\n\n/* One surface, several actions: a shared border and radius, hairline dividers, no gaps. A single soft highlight travels under the\n   segments instead of each one lighting up; the dividers beside it step aside so it reads as one shape. The group sets the\n   --bg-* variables its parts read. */\nconst groupBase =\n  \"group/bg relative isolate inline-flex max-w-full items-stretch overflow-x-auto overscroll-x-contain rounded-(--bg-radius) border border-line bg-surface align-middle text-ink [scrollbar-width:none] aria-disabled:opacity-52 [&::-webkit-scrollbar]:hidden [--bg-divider-inset:11px] [--bg-divider:var(--line)] [--focus-outline-offset:-2px] [--bg-gap:var(--space-2)] [--bg-h:var(--control-height-md)] [--bg-highlight-pressed:color-mix(in_oklab,var(--hover),var(--line-strong)_30%)] [--bg-highlight:var(--hover)] [--bg-inset:3px] [--bg-muted-ink:var(--ink-2)] [--bg-pad-x:14px] [--bg-radius:var(--radius-button)]\";\nconst groupSizes: Record<ButtonGroupSize, string> = {\n  sm: \"[--bg-divider-inset:9px] [--bg-gap:6px] [--bg-h:var(--control-height-sm)] [--bg-pad-x:12px]\",\n  md: \"[--bg-h:var(--control-height-md)]\",\n};\n/* Solid is the primary group: the ink fill of a primary button, with the highlight and dividers mixed from the page colour. */\nconst groupVariants: Record<ButtonGroupVariant, string> = {\n  outline: \"\",\n  solid:\n    \"border-ink bg-ink text-page [--bg-divider:color-mix(in_oklab,var(--page)_20%,var(--ink))] [--bg-highlight-pressed:color-mix(in_oklab,var(--page)_22%,var(--ink))] [--bg-highlight:color-mix(in_oklab,var(--page)_13%,var(--ink))] [--bg-muted-ink:color-mix(in_oklab,var(--page)_72%,var(--ink))]\",\n};\n/* The travelling highlight. Motion sets its box to the active segment; the visible shape sits inset so the border stays crisp. */\nconst highlightClass =\n  \"pointer-events-none absolute top-0 left-0 z-0 before:absolute before:inset-(--bg-inset) before:rounded-[calc(var(--bg-radius)-var(--bg-inset)-1px)] before:bg-(--bg-highlight) before:transition-[background-color] before:duration-fast before:ease-standard before:content-[''] data-pressed:before:bg-(--bg-highlight-pressed) data-pressed:before:duration-instant motion-reduce:before:transition-none\";\n/* Hairline dividers sit between segments (a segment after the highlight has none) and fade where the highlight arrives, so it\n   never sits beside a line. Collapsed rows keep the icon and drop the label; the label moves to aria-label and the tooltip. */\nconst segmentClass =\n  \"group/seg relative z-1 m-0 inline-flex h-[calc(var(--bg-h)-2px)] min-w-[calc(var(--bg-h)-2px)] flex-none cursor-pointer items-center justify-center rounded-button border-0 bg-transparent px-(--bg-pad-x) text-sm leading-none font-medium tracking-body whitespace-nowrap text-inherit no-underline select-none [-webkit-tap-highlight-color:transparent] aria-disabled:cursor-not-allowed group-aria-disabled/bg:cursor-not-allowed group-data-[orientation=vertical]/bg:w-full group-data-[orientation=vertical]/bg:justify-start group-data-compact/bg:data-collapsible:w-[calc(var(--bg-h)-2px)] group-data-compact/bg:data-collapsible:p-0 before:pointer-events-none before:absolute before:top-(--bg-divider-inset) before:bottom-(--bg-divider-inset) before:-left-[.5px] before:w-px before:bg-(--bg-divider) before:transition-opacity before:duration-fast before:ease-standard before:content-[''] data-quiet:before:opacity-0 group-data-[orientation=vertical]/bg:before:top-[-.5px] group-data-[orientation=vertical]/bg:before:right-(--bg-divider-inset) group-data-[orientation=vertical]/bg:before:bottom-auto group-data-[orientation=vertical]/bg:before:left-(--bg-divider-inset) group-data-[orientation=vertical]/bg:before:h-px group-data-[orientation=vertical]/bg:before:w-auto motion-reduce:before:transition-none [:not([data-key])+&]:before:hidden\";\nconst iconOnlyClass =\n  \"w-[calc(var(--bg-h)-2px)] p-0 group-data-[orientation=vertical]/bg:justify-center group-data-[orientation=vertical]/bg:p-0\";\nconst triggerClass =\n  \"w-[calc(var(--bg-h)-6px)] min-w-0 p-0 text-(--bg-muted-ink) transition-[color] duration-fast ease-standard data-[state=open]:text-inherit group-data-[orientation=vertical]/bg:justify-center group-data-[orientation=vertical]/bg:p-0\";\n/* The press answers inside the segment: content dips and springs back, the group and its neighbours stay still. The chevron\n   anchors the menu, so it answers with the deeper highlight only. */\nconst contentClass =\n  \"relative inline-flex items-center [transition:transform_var(--duration-spring)_var(--ease-spring),opacity_var(--duration-fast)_var(--ease-standard)] group-aria-disabled/seg:opacity-40 motion-reduce:transition-none\";\nconst pressClass =\n  \"group-[:active:not([aria-disabled=true])]/seg:[transform:scale(.97)] group-[:active:not([aria-disabled=true])]/seg:duration-instant group-[:active:not([aria-disabled=true])]/seg:ease-standard motion-reduce:group-[:active:not([aria-disabled=true])]/seg:[transform:none]\";\nconst iconOnlyPressClass = \"group-[:active:not([aria-disabled=true])]/seg:[transform:scale(.9)]\";\nconst labelClass = \"inline-block group-data-compact/bg:data-collapsible:hidden\";\nconst srOnlyClass = \"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\n/* The menu grows from the group's edge: offset toward it, scale from the Radix origin, and leave faster than it arrives.\n   Transitions instead of keyframes, so a close that interrupts the open reverses from where the menu is. Radix unmounts when\n   the exit animation ends, so a no-op keyframe (button-group-menu-exit) times the exit while the transition does the visual work. */\nconst menuClass =\n  \"group/menu z-60 max-w-(--radix-dropdown-menu-content-available-width) min-w-[min(13rem,var(--radix-dropdown-menu-content-available-width))] origin-(--radix-dropdown-menu-content-transform-origin) rounded-panel border border-line bg-surface p-[5px] text-ink shadow-floating [--menu-x:0px] [--menu-y:-5px] [transition:opacity_var(--duration-fast)_var(--ease-enter),transform_var(--duration-spring)_var(--ease-spring)] data-[side=left]:[--menu-x:5px] data-[side=left]:[--menu-y:0px] data-[side=right]:[--menu-x:-5px] data-[side=right]:[--menu-y:0px] data-[side=top]:[--menu-y:5px] data-[state=open]:starting:opacity-0 data-[state=open]:starting:[transform:translate(var(--menu-x),var(--menu-y))_scale(.97)] data-[state=closed]:pointer-events-none data-[state=closed]:animate-button-group-menu-exit data-[state=closed]:opacity-0 data-[state=closed]:[transform:translate(calc(var(--menu-x)*.5),calc(var(--menu-y)*.5))_scale(.985)] data-[state=closed]:[transition:opacity_130ms_var(--ease-standard),transform_130ms_var(--ease-standard)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_var(--duration-instant)_linear] motion-reduce:data-[state=closed]:[transform:none] motion-reduce:data-[state=closed]:[transition:opacity_var(--duration-instant)_linear]\";\nconst itemClass =\n  \"flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[calc(var(--radius-panel)-6px)] px-[11px] text-sm leading-[inherit] text-inherit no-underline outline-none [transition:opacity_var(--duration-standard)_var(--ease-enter)_calc(min(var(--i,0),4)*35ms),transform_var(--duration-standard)_var(--ease-enter)_calc(min(var(--i,0),4)*35ms)] data-disabled:cursor-default data-disabled:opacity-45 data-highlighted:bg-hover group-data-[state=open]/menu:starting:opacity-0 group-data-[state=open]/menu:starting:[transform:translate(calc(var(--menu-x)*.4),calc(var(--menu-y)*.4))] motion-reduce:transition-none\";\nconst itemIconClass = \"inline-flex flex-none text-ink-2 [&_svg]:size-4 [&_svg]:[stroke-width:1.75]\";\n\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst textIn: TargetAndTransition = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: -3,\n  filter: `blur(${motionTokens.blur.soft}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n\n/** Names the icon element, so swapping Link for Check morphs while a re-render of the same icon stays still. */\nfunction iconKey(node: ReactNode): string {\n  if (!isValidElement(node))\n    return typeof node === \"string\" || typeof node === \"number\" || typeof node === \"bigint\" ? String(node) : \"\";\n  const type = node.type as string | { displayName?: string; name?: string };\n  return typeof type === \"string\" ? type : (type?.displayName ?? type?.name ?? \"icon\");\n}\n\n/** Springs the slot to the natural width of its content when the label changes; other resizes (a late web font, collapsing labels) jump. */\nfunction useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key),\n    armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current,\n      slot = node?.parentElement;\n    if (!node || !slot || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.contentRect.width;\n      if (!next || !measured || reduced || performance.now() > armedUntil.current) {\n        measured = next > 0;\n        width.jump(next || \"auto\");\n        delete slot.dataset.morphing;\n        return;\n      }\n      slot.dataset.morphing = \"\";\n      animate(width, next, {\n        ...motionTokens.spring.morph,\n        onComplete: () => {\n          delete slot.dataset.morphing;\n        },\n      });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [content, reduced, width]);\n  return width;\n}\n\n/**\n * Icon and label of one segment. Every reserved label sits invisibly in the same grid cell, so the segment is as wide as its widest\n * state and a new label crossfades inside a fixed box: it rises in from a soft blur while the old one lifts away. A label nobody\n * reserved still never snaps: the slot springs to its width.\n */\nfunction SegmentContent({ item, reduced }: { item: ButtonGroupItem; reduced: boolean }) {\n  const contentRef = useRef<HTMLSpanElement>(null);\n  const key = `${iconKey(item.icon)}|${item.content !== undefined ? \"\\u0000content\" : item.iconOnly ? \"\" : item.label}`;\n  const width = useMorphWidth(contentRef, key, reduced);\n  const sizers = item.iconOnly || item.content !== undefined ? [] : [...new Set([item.label, ...(item.reserve ?? [])])];\n  return (\n    <motion.span\n      className=\"relative inline-flex min-w-0 items-center justify-center data-morphing:[clip-path:inset(-50%_calc(var(--space-3)*-1))]\"\n      style={{ width }}\n      aria-hidden=\"true\"\n    >\n      <span ref={contentRef} className=\"inline-grid flex-none items-center justify-items-center *:[grid-area:1/1]\">\n        {sizers.map((text) => (\n          <span\n            key={text}\n            className=\"pointer-events-none invisible inline-flex items-center gap-(--bg-gap) whitespace-nowrap\"\n          >\n            {item.icon ? <span className=\"inline-block size-4 flex-none\" /> : null}\n            <span className={labelClass} data-collapsible={item.icon ? \"\" : undefined}>\n              {text}\n            </span>\n          </span>\n        ))}\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={key}\n            className=\"inline-flex items-center gap-(--bg-gap) whitespace-nowrap\"\n            initial={reduced ? fadeIn : textIn}\n            animate={rest}\n            exit={reduced ? fadeOut : textOut}\n            transition={\n              reduced\n                ? { duration: motionTokens.duration.instant }\n                : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n            }\n          >\n            {item.icon ? (\n              <span className=\"inline-flex flex-none [&_svg]:size-4 [&_svg]:[stroke-width:1.75]\">{item.icon}</span>\n            ) : null}\n            {item.content !== undefined ? (\n              <span className=\"inline-flex items-center tabular-nums\">{item.content}</span>\n            ) : item.iconOnly ? null : (\n              <span className={labelClass} data-collapsible={item.icon ? \"\" : undefined}>\n                {item.label}\n              </span>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\nconst inside = (node: HTMLElement, x: number, y: number) => {\n  const rect = node.getBoundingClientRect();\n  return x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;\n};\n/** Segments are found by their data-key, so the group needs no ref per segment. */\nconst segmentsIn = (root: HTMLElement | null) =>\n  Array.from(root?.querySelectorAll<HTMLElement>(\":scope > [data-key]\") ?? []);\nconst segmentIn = (root: HTMLElement | null, key: string | null) =>\n  key === null ? undefined : segmentsIn(root).find((node) => node.dataset.key === key);\n/** The segment's box inside the group at sub-pixel precision, undoing any scale an ancestor applies. */\nfunction boxOf(group: HTMLElement, node: HTMLElement) {\n  const outer = group.getBoundingClientRect(),\n    inner = node.getBoundingClientRect();\n  // offsetWidth is rounded to whole pixels, so only a real transform (more than a pixel apart) counts as scale.\n  const scale =\n    group.offsetWidth && Math.abs(outer.width - group.offsetWidth) > 1 ? outer.width / group.offsetWidth : 1;\n  return [\n    (inner.left - outer.left) / scale - group.clientLeft + group.scrollLeft,\n    (inner.top - outer.top) / scale - group.clientTop + group.scrollTop,\n    inner.width / scale,\n    inner.height / scale,\n  ];\n}\nconst inert = (node: HTMLElement) =>\n  (node as HTMLButtonElement).disabled || node.getAttribute(\"aria-disabled\") === \"true\";\n\n/**\n * Related actions joined into one surface: a shared border and radius, hairline dividers, no gaps. One soft highlight glides\n * between segments under the pointer or keyboard focus, the pressed segment answers in place, and an optional chevron\n * segment opens more actions in a menu aligned to the group's edge.\n */\nexport function ButtonGroup({\n  items,\n  menu,\n  label,\n  variant = \"outline\",\n  size = \"md\",\n  orientation = \"horizontal\",\n  collapseLabels = true,\n  disabled = false,\n  className,\n  style,\n}: ButtonGroupProps) {\n  const reduced = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null);\n  const vertical = orientation === \"vertical\";\n\n  // The highlight sits on the pointer's segment, else the pressed one (touch), else the open menu's chevron, else keyboard focus.\n  const [hover, setHover] = useState<string | null>(null);\n  const [pressed, setPressed] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const active = hover ?? pressed ?? (menuOpen ? MENU : null) ?? focused;\n  const keys = [...items.map((item) => item.id), ...(menu ? [MENU] : [])];\n  const activeIndex = active ? keys.indexOf(active) : -1;\n  const inertKey = `${disabled}${items.map((item) => (item.disabled ? 1 : 0)).join(\"\")}`;\n\n  const x = useMotionValue(0),\n    y = useMotionValue(0),\n    width = useMotionValue(0),\n    height = useMotionValue(0),\n    opacity = useMotionValue(0);\n  const shown = useRef<string | null>(null);\n\n  useLayoutEffect(() => {\n    const node = segmentIn(root.current, active);\n    // Hovering a disabled segment shows nothing; focus on one still shows where the keyboard is.\n    if (!node || (inert(node) && active !== focused)) {\n      if (shown.current)\n        animate(opacity, 0, {\n          duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast,\n          ease: [...motionTokens.ease.standard],\n        });\n      shown.current = null;\n      return;\n    }\n    const target = boxOf(root.current!, node);\n    const values = [x, y, width, height];\n    if (!shown.current || reduced) {\n      // Arriving from nowhere, it appears in place; only moves between segments travel.\n      values.forEach((value, index) => value.jump(target[index]!));\n      animate(opacity, 1, {\n        duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast,\n        ease: [...motionTokens.ease.standard],\n      });\n    } else {\n      values.forEach((value, index) => animate(value, target[index]!, motionTokens.spring.snappy));\n      animate(opacity, 1, { duration: motionTokens.duration.fast });\n    }\n    shown.current = active;\n    // inertKey re-checks a segment that turns disabled under a still pointer.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [active, inertKey, reduced, x, y, width, height, opacity]);\n\n  // Segments resize when a label morphs or labels collapse; the highlight stays locked to its segment.\n  const keyList = keys.join(\"\\u0000\");\n  useEffect(() => {\n    const group = root.current;\n    if (!group || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const node = segmentIn(group, shown.current);\n      if (!node) return;\n      const target = boxOf(group, node);\n      [x, y, width, height].forEach((value, index) => {\n        if (Math.abs(value.get() - target[index]!) < 0.01) return;\n        if (value.isAnimating() && !reduced) animate(value, target[index]!, motionTokens.spring.snappy);\n        else value.jump(target[index]!);\n      });\n    });\n    observer.observe(group);\n    segmentsIn(group).forEach((node) => observer.observe(node));\n    return () => observer.disconnect();\n  }, [keyList, reduced, x, y, width, height]);\n\n  // Labels collapse to icons when the row overflows, and come back once the container is wide enough for the full row again.\n  const [overflowing, setCompact] = useState(false);\n  const fullWidth = useRef(0),\n    blockedAt = useRef(0);\n  const collapsible =\n    collapseLabels && !vertical && items.some((item) => item.icon && !item.iconOnly && item.content === undefined);\n  const compact = collapsible && overflowing;\n  useEffect(() => {\n    const group = root.current,\n      parent = group?.parentElement;\n    if (!group || !parent || !collapsible || typeof ResizeObserver === \"undefined\") return;\n    const check = () => {\n      const box = getComputedStyle(parent);\n      const available = parent.clientWidth - parseFloat(box.paddingLeft) - parseFloat(box.paddingRight);\n      if (group.dataset.compact === undefined) {\n        if (group.scrollWidth > group.clientWidth + 1) {\n          fullWidth.current = group.scrollWidth + 2;\n          blockedAt.current = available;\n          setCompact(true);\n        }\n      } else if (available > blockedAt.current && available >= fullWidth.current) setCompact(false);\n    };\n    const observer = new ResizeObserver(check);\n    observer.observe(group);\n    observer.observe(parent);\n    return () => observer.disconnect();\n  }, [collapsible, keyList]);\n\n  // Pressing tracks the segment under the finger, so touch gets the same highlight a pointer gets on hover.\n  useEffect(() => {\n    if (!pressed) return;\n    const release = () => setPressed(null);\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    return () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n    };\n  }, [pressed]);\n\n  const hit = (event: PointerEvent) => {\n    for (const node of segmentsIn(root.current))\n      if (inside(node, event.clientX, event.clientY)) return inert(node) ? null : (node.dataset.key ?? null);\n    return null;\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"touch\") setHover(hit(event));\n  };\n  const onPointerLeave = () => setHover(null);\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button === 0) setPressed(hit(event));\n  };\n\n  // Keyboard focus shows the highlight; a mouse click focuses without it, so nothing stays lit after the pointer leaves.\n  const onFocus = (event: FocusEvent<HTMLDivElement>) => {\n    const target = event.target as HTMLElement;\n    const key = target.dataset.key;\n    if (key === undefined) return;\n    let visible = true;\n    try {\n      visible = target.matches(\":focus-visible\");\n    } catch {\n      /* older engines */\n    }\n    setFocused(visible ? key : null);\n  };\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (!root.current?.contains(event.relatedTarget as Node | null)) setFocused(null);\n  };\n\n  // Tab visits every segment in order; arrow keys along the orientation, Home and End also move between them.\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.defaultPrevented) return;\n    // Disabled segments stay focusable (aria-disabled), so the keyboard can find them and hear why nothing happens.\n    const list = segmentsIn(root.current).filter((node) => !(node as HTMLButtonElement).disabled);\n    const index = list.findIndex((node) => node === document.activeElement);\n    if (index < 0) return;\n    const rtl = !vertical && getComputedStyle(root.current!).direction === \"rtl\";\n    const forward = vertical ? \"ArrowDown\" : rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const backward = vertical ? \"ArrowUp\" : rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    const last = list.length - 1;\n    const target =\n      event.key === forward\n        ? index === last\n          ? 0\n          : index + 1\n        : event.key === backward\n          ? index === 0\n            ? last\n            : index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? last\n              : -1;\n    if (target < 0) return;\n    event.preventDefault();\n    list[target]!.focus();\n  };\n\n  // A label that changes right after its segment is pressed, such as \"Copied\", is announced once; the quiet revert is not.\n  const [announcement, setAnnouncement] = useState(\"\");\n  const labels = useRef<Map<string, string> | null>(null);\n  const activated = useRef({ key: \"\", at: 0 });\n  const labelList = items.map((item) => `${item.id}\\u0000${item.label}`).join(\"\\u0001\");\n  useEffect(() => {\n    const previous = labels.current;\n    labels.current = new Map(items.map((item) => [item.id, item.label]));\n    if (!previous) return;\n    const recent = performance.now() - activated.current.at < 1000;\n    const changed = items.filter(\n      (item) =>\n        recent &&\n        item.id === activated.current.key &&\n        previous.has(item.id) &&\n        previous.get(item.id) !== item.label &&\n        item.content === undefined,\n    );\n    if (changed.length) setAnnouncement(changed.map((item) => item.label).join(\", \"));\n    // labelList carries the only part of items this effect reads.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [labelList]);\n\n  const select = (item: ButtonGroupItem) => {\n    activated.current = { key: item.id, at: performance.now() };\n    item.onSelect?.();\n  };\n  const quiet = (index: number) =>\n    activeIndex >= 0 && (index === activeIndex || index === activeIndex + 1) ? \"\" : undefined;\n\n  const segments = items.map((item, index) => {\n    const off = item.disabled && !disabled;\n    const named = item.iconOnly || item.content !== undefined || (compact && !!item.icon);\n    const common = {\n      className: cn(segmentClass, item.iconOnly && iconOnlyClass),\n      \"data-key\": item.id,\n      \"data-quiet\": quiet(index),\n      \"data-collapsible\": item.icon && !item.iconOnly && item.content === undefined ? \"\" : undefined,\n      \"aria-label\": named ? item.label : undefined,\n      title: item.iconOnly || (compact && item.icon) ? item.label : undefined,\n    };\n    const body = (\n      <span className={cn(contentClass, pressClass, item.iconOnly && iconOnlyPressClass)}>\n        <SegmentContent item={item} reduced={reduced} />\n      </span>\n    );\n    if (item.href && !off && !disabled)\n      return (\n        <a key={item.id} {...common} href={item.href} onClick={() => select(item)}>\n          {body}\n          {named ? null : <span className={srOnlyClass}>{item.label}</span>}\n        </a>\n      );\n    return (\n      <button\n        key={item.id}\n        {...common}\n        type=\"button\"\n        disabled={disabled}\n        aria-disabled={off || undefined}\n        onClick={off ? undefined : () => select(item)}\n      >\n        {body}\n        {named ? null : <span className={srOnlyClass}>{item.label}</span>}\n      </button>\n    );\n  });\n\n  const classes = cn(\n    groupBase,\n    groupSizes[size],\n    groupVariants[variant],\n    vertical && \"flex-col overflow-visible [--bg-divider-inset:10px]\",\n    className,\n  );\n  const group = (\n    // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a group of action buttons is not a form fieldset; the handlers drive the shared highlight and arrow-key movement between the buttons inside\n    <div\n      ref={root}\n      className={classes}\n      style={style}\n      role=\"group\"\n      aria-label={label}\n      aria-disabled={disabled || undefined}\n      data-orientation={orientation}\n      data-compact={compact ? \"\" : undefined}\n      onPointerMove={onPointerMove}\n      onPointerLeave={onPointerLeave}\n      onPointerDown={onPointerDown}\n      onFocus={onFocus}\n      onBlur={onBlur}\n      onKeyDown={onKeyDown}\n    >\n      <motion.span\n        className={highlightClass}\n        style={{ x, y, width, height, opacity }}\n        data-pressed={pressed && pressed === active ? \"\" : undefined}\n        aria-hidden=\"true\"\n      />\n      {segments}\n      {menu ? (\n        <DropdownPrimitive.Trigger\n          className={cn(segmentClass, triggerClass)}\n          data-key={MENU}\n          data-quiet={quiet(items.length)}\n          aria-label={menu.label}\n          title={menu.label}\n          disabled={disabled}\n        >\n          <span className={contentClass}>\n            <ChevronDown\n              className=\"size-4 [stroke-width:1.75] transition-transform duration-spring ease-spring group-data-[state=open]/seg:rotate-180 motion-reduce:transition-none\"\n              aria-hidden=\"true\"\n            />\n          </span>\n        </DropdownPrimitive.Trigger>\n      ) : null}\n      <span className={srOnlyClass} aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n\n  if (!menu) return group;\n  // Non-modal: no scroll lock, so opening the menu never shifts the page by a scrollbar's width.\n  return (\n    <DropdownPrimitive.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>\n      {group}\n      <DropdownPrimitive.Portal>\n        <DropdownPrimitive.Content\n          className={menuClass}\n          side=\"bottom\"\n          align={vertical ? \"start\" : \"end\"}\n          alignOffset={-1}\n          sideOffset={6}\n          collisionPadding={12}\n          loop\n        >\n          {menu.items.map((action, index) => {\n            const classes = cn(\n              itemClass,\n              action.destructive && \"text-red data-highlighted:bg-[color-mix(in_oklab,var(--red)_8%,var(--surface))]\",\n            );\n            const body = (\n              <>\n                {action.icon ? (\n                  <span className={cn(itemIconClass, action.destructive && \"text-red\")} aria-hidden=\"true\">\n                    {action.icon}\n                  </span>\n                ) : null}\n                <span className=\"min-w-0 truncate\">{action.label}</span>\n              </>\n            );\n            return action.href ? (\n              <DropdownPrimitive.Item\n                key={action.id}\n                className={classes}\n                style={{ \"--i\": index } as CSSProperties}\n                disabled={action.disabled}\n                onSelect={action.onSelect}\n                asChild\n              >\n                <a href={action.href}>{body}</a>\n              </DropdownPrimitive.Item>\n            ) : (\n              <DropdownPrimitive.Item\n                key={action.id}\n                className={classes}\n                style={{ \"--i\": index } as CSSProperties}\n                disabled={action.disabled}\n                onSelect={action.onSelect}\n              >\n                {body}\n              </DropdownPrimitive.Item>\n            );\n          })}\n        </DropdownPrimitive.Content>\n      </DropdownPrimitive.Portal>\n    </DropdownPrimitive.Root>\n  );\n}\n\nexport default ButtonGroup;\n",
      "type": "registry:component",
      "target": "@components/iris/button-group/button-group.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/button-group",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ButtonGroup",
    "tags": [
      "action",
      "group",
      "toolbar",
      "new"
    ],
    "docs": "https://irisui.dev/components/button-group"
  }
}
