{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/button-group/button-group.tsx","type":"registry:component","target":"@components/arc/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 styles from \"./button-group.module.css\";\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\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 = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\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)) return node == null || typeof node === \"boolean\" ? \"\" : 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), 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, 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) { measured = next > 0; width.jump(next || \"auto\"); delete slot.dataset.morphing; return; }\n      slot.dataset.morphing = \"\";\n      animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });\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 <motion.span className={styles.slot} style={{ width }} aria-hidden=\"true\">\n    <span ref={contentRef} className={styles.slotContent}>\n      {sizers.map(text => <span key={text} className={`${styles.phase} ${styles.sizer}`}>{item.icon ? <span className={styles.iconBox} /> : null}<span className={styles.label} data-collapsible={item.icon ? \"\" : undefined}>{text}</span></span>)}\n      <AnimatePresence initial={false}>\n        <motion.span key={key} className={styles.phase} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>\n          {item.icon ? <span className={styles.icon}>{item.icon}</span> : null}\n          {item.content !== undefined ? <span className={styles.value}>{item.content}</span> : item.iconOnly ? null : <span className={styles.label} data-collapsible={item.icon ? \"\" : undefined}>{item.label}</span>}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  </motion.span>;\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) => Array.from(root?.querySelectorAll<HTMLElement>(\":scope > [data-key]\") ?? []);\nconst segmentIn = (root: HTMLElement | null, key: string | null) => 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(), 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 = group.offsetWidth && Math.abs(outer.width - group.offsetWidth) > 1 ? outer.width / group.offsetWidth : 1;\n  return [(inner.left - outer.left) / scale - group.clientLeft + group.scrollLeft, (inner.top - outer.top) / scale - group.clientTop + group.scrollTop, inner.width / scale, inner.height / scale];\n}\nconst inert = (node: HTMLElement) => (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({ items, menu, label, variant = \"outline\", size = \"md\", orientation = \"horizontal\", collapseLabels = true, disabled = false, className, style }: 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), y = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0), 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) animate(opacity, 0, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] });\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, { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] });\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]!) < .01) return;\n        if (value.isAnimating() && !reduced) animate(value, target[index]!, motionTokens.spring.snappy); 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), blockedAt = useRef(0);\n  const collapsible = collapseLabels && !vertical && items.some(item => item.icon && !item.iconOnly && item.content === undefined);\n  const compact = collapsible && overflowing;\n  useEffect(() => {\n    const group = root.current, 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) { fullWidth.current = group.scrollWidth + 2; blockedAt.current = available; setCompact(true); }\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 () => { window.removeEventListener(\"pointerup\", release); window.removeEventListener(\"pointercancel\", release); };\n  }, [pressed]);\n\n  const hit = (event: PointerEvent) => {\n    for (const node of segmentsIn(root.current)) 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>) => { if (event.pointerType !== \"touch\") setHover(hit(event)); };\n  const onPointerLeave = () => setHover(null);\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.button === 0) setPressed(hit(event)); };\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 { visible = target.matches(\":focus-visible\"); } catch { /* older engines */ }\n    setFocused(visible ? key : null);\n  };\n  const onBlur = (event: FocusEvent<HTMLDivElement>) => { if (!root.current?.contains(event.relatedTarget as Node | null)) setFocused(null); };\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 = event.key === forward ? (index === last ? 0 : index + 1) : event.key === backward ? (index === 0 ? last : index - 1) : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : -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(item => recent && item.id === activated.current.key && previous.has(item.id) && previous.get(item.id) !== item.label && item.content === undefined);\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) => { activated.current = { key: item.id, at: performance.now() }; item.onSelect?.(); };\n  const quiet = (index: number) => 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: [styles.segment, item.iconOnly ? styles.iconOnly : \"\"].filter(Boolean).join(\" \"),\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 = <span className={styles.content}><SegmentContent item={item} reduced={reduced} /></span>;\n    if (item.href && !off && !disabled) return <a key={item.id} {...common} href={item.href} onClick={() => select(item)}>{body}{named ? null : <span className={styles.srOnly}>{item.label}</span>}</a>;\n    return <button key={item.id} {...common} type=\"button\" disabled={disabled} aria-disabled={off || undefined} onClick={off ? undefined : () => select(item)}>{body}{named ? null : <span className={styles.srOnly}>{item.label}</span>}</button>;\n  });\n\n  const classes = [styles.group, styles[variant], styles[size], vertical ? styles.vertical : \"\", compact ? styles.compact : \"\", className].filter(Boolean).join(\" \");\n  const group = <div ref={root} className={classes} style={style} role=\"group\" aria-label={label} aria-disabled={disabled || undefined} data-orientation={orientation} data-compact={compact ? \"\" : undefined} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onPointerDown={onPointerDown} onFocus={onFocus} onBlur={onBlur} onKeyDown={onKeyDown}>\n    <motion.span className={styles.highlight} style={{ x, y, width, height, opacity }} data-pressed={pressed && pressed === active ? \"\" : undefined} aria-hidden=\"true\" />\n    {segments}\n    {menu ? <DropdownPrimitive.Trigger className={[styles.segment, styles.trigger].join(\" \")} data-key={MENU} data-quiet={quiet(items.length)} aria-label={menu.label} title={menu.label} disabled={disabled}>\n      <span className={styles.content}><ChevronDown className={styles.chevron} aria-hidden=\"true\" /></span>\n    </DropdownPrimitive.Trigger> : null}\n    <span className={styles.srOnly} aria-live=\"polite\">{announcement}</span>\n  </div>;\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 <DropdownPrimitive.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>\n    {group}\n    <DropdownPrimitive.Portal>\n      <DropdownPrimitive.Content className={styles.menu} side=\"bottom\" align={vertical ? \"start\" : \"end\"} alignOffset={-1} sideOffset={6} collisionPadding={12} loop>\n        {menu.items.map((action, index) => {\n          const classes = [styles.item, action.destructive ? styles.destructive : \"\"].filter(Boolean).join(\" \");\n          const body = <>{action.icon ? <span className={styles.itemIcon} aria-hidden=\"true\">{action.icon}</span> : null}<span className={styles.itemLabel}>{action.label}</span></>;\n          return action.href\n            ? <DropdownPrimitive.Item key={action.id} className={classes} style={{ \"--i\": index } as CSSProperties} disabled={action.disabled} onSelect={action.onSelect} asChild><a href={action.href}>{body}</a></DropdownPrimitive.Item>\n            : <DropdownPrimitive.Item key={action.id} className={classes} style={{ \"--i\": index } as CSSProperties} disabled={action.disabled} onSelect={action.onSelect}>{body}</DropdownPrimitive.Item>;\n        })}\n      </DropdownPrimitive.Content>\n    </DropdownPrimitive.Portal>\n  </DropdownPrimitive.Root>;\n}\n\nexport default ButtonGroup;\n"},{"path":"registry/components/button-group/button-group.module.css","type":"registry:component","target":"@components/arc/button-group/button-group.module.css","content":"/* 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. */\n.group { --bg-h: var(--control-height-md); --bg-pad-x: 14px; --bg-inset: 3px; --bg-radius: var(--radius-control); --bg-divider: var(--border); --bg-divider-inset: 11px; --bg-highlight: var(--surface-muted); --bg-highlight-pressed: color-mix(in oklab, var(--surface-muted), var(--border-strong) 30%); --bg-muted-ink: var(--text-secondary);\n  position: relative; isolation: isolate; display: inline-flex; max-width: 100%; align-items: stretch; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; border: 1px solid var(--border); border-radius: var(--bg-radius); background: var(--surface); color: var(--foreground); vertical-align: middle; }\n.group::-webkit-scrollbar { display: none; }\n.sm { --bg-h: var(--control-height-sm); --bg-pad-x: 12px; --bg-divider-inset: 9px; }\n.md { --bg-h: var(--control-height-md); }\n/* Solid is the primary group: the foreground fill of a primary button, with the highlight and dividers mixed from the page color. */\n.solid { --bg-divider: color-mix(in oklab, var(--background) 20%, var(--foreground)); --bg-highlight: color-mix(in oklab, var(--background) 13%, var(--foreground)); --bg-highlight-pressed: color-mix(in oklab, var(--background) 22%, var(--foreground)); --bg-muted-ink: color-mix(in oklab, var(--background) 72%, var(--foreground)); border-color: var(--foreground); background: var(--foreground); color: var(--background); }\n.vertical { flex-direction: column; overflow: visible; --bg-divider-inset: 10px; }\n.group[aria-disabled=\"true\"] { opacity: .52; }\n\n/* The travelling highlight. Motion sets its box to the active segment; the visible shape sits inset so the border stays crisp. */\n.highlight { position: absolute; z-index: 0; top: 0; left: 0; pointer-events: none; }\n.highlight::before { position: absolute; inset: var(--bg-inset); border-radius: calc(var(--bg-radius) - var(--bg-inset) - 1px); background: var(--bg-highlight); content: \"\"; transition: background-color var(--duration-fast) var(--ease-standard); }\n.highlight[data-pressed]::before { background: var(--bg-highlight-pressed); transition-duration: var(--duration-instant); }\n\n.segment { position: relative; z-index: 1; display: inline-flex; flex: none; min-width: calc(var(--bg-h) - 2px); height: calc(var(--bg-h) - 2px); align-items: center; justify-content: center; margin: 0; padding: 0 var(--bg-pad-x); border: 0; border-radius: 0; background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 1; white-space: nowrap; text-decoration: none; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }\n.vertical .segment { width: 100%; justify-content: flex-start; }\n.vertical .iconOnly, .vertical .trigger { justify-content: center; padding: 0; }\n.iconOnly { width: calc(var(--bg-h) - 2px); padding: 0; }\n.trigger { width: calc(var(--bg-h) - 6px); min-width: 0; padding: 0; color: var(--bg-muted-ink); transition: color var(--duration-fast) var(--ease-standard); }\n.trigger[data-state=\"open\"] { color: inherit; }\n.segment[aria-disabled=\"true\"] { cursor: not-allowed; }\n.segment[aria-disabled=\"true\"] .content { opacity: .4; }\n.group[aria-disabled=\"true\"] .segment { cursor: not-allowed; }\n/* Collapsed rows keep the icon and drop the label; the label moves to aria-label and the tooltip. */\n.compact .segment[data-collapsible] { width: calc(var(--bg-h) - 2px); padding: 0; }\n.compact .label[data-collapsible] { display: none; }\n\n/* Hairline dividers sit between segments and fade where the highlight arrives, so it never sits beside a line. */\n.segment + .segment::before { position: absolute; top: var(--bg-divider-inset); bottom: var(--bg-divider-inset); left: -.5px; width: 1px; background: var(--bg-divider); content: \"\"; pointer-events: none; transition: opacity var(--duration-fast) var(--ease-standard); }\n.vertical .segment + .segment::before { top: -.5px; right: var(--bg-divider-inset); bottom: auto; left: var(--bg-divider-inset); width: auto; height: 1px; }\n.segment[data-quiet]::before { opacity: 0; }\n\n/* The press answers inside the segment: content dips and springs back, the group and its neighbours stay still. */\n.content { position: relative; display: inline-flex; align-items: center; transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }\n.segment:active:not([aria-disabled=\"true\"]) .content { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n.iconOnly:active:not([aria-disabled=\"true\"]) .content { transform: scale(.9); }\n/* The chevron anchors the menu, so it answers with the deeper highlight only. */\n.trigger:active .content { transform: none; }\n.chevron { width: 16px; height: 16px; stroke-width: 1.75; transition: transform var(--duration-spring) var(--ease-spring); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n\n/* Reserved labels and the visible one share one grid cell, so the widest state sets the width and a change crossfades in place.\n   A label nobody reserved springs the slot to its width instead; content is clipped only while that happens. */\n.slot { position: relative; display: inline-flex; min-width: 0; align-items: center; justify-content: center; }\n.slot[data-morphing] { clip-path: inset(-50% calc(var(--space-3) * -1)); }\n.slotContent { display: inline-grid; flex: none; align-items: center; justify-items: center; }\n.slotContent > * { grid-area: 1 / 1; }\n.phase { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }\n.sizer { visibility: hidden; pointer-events: none; }\n.iconBox { display: inline-block; width: 16px; height: 16px; flex: none; }\n.sm .phase { gap: 6px; }\n.icon { display: inline-flex; flex: none; }\n.icon svg { width: 16px; height: 16px; stroke-width: 1.75; }\n.value { display: inline-flex; align-items: center; font-variant-numeric: tabular-nums; }\n.label { display: inline-block; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\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. */\n.menu { --menu-x: 0px; --menu-y: -5px; z-index: 60; min-width: min(13rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }\n.menu[data-side=\"top\"] { --menu-y: 5px; }\n.menu[data-side=\"left\"] { --menu-x: 5px; --menu-y: 0px; }\n.menu[data-side=\"right\"] { --menu-x: -5px; --menu-y: 0px; }\n@starting-style {\n  .menu[data-state=\"open\"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }\n  .menu[data-state=\"open\"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }\n}\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.menu[data-state=\"closed\"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity 130ms var(--ease-standard), transform 130ms var(--ease-standard); animation: menu-exit 130ms linear both; pointer-events: none; }\n.item { display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: inherit; font-size: var(--text-sm); text-decoration: none; cursor: pointer; 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); }\n.item[data-highlighted] { background: var(--surface-muted); }\n.item[data-disabled] { cursor: default; opacity: .45; }\n.itemIcon { display: inline-flex; flex: none; color: var(--text-secondary); }\n.itemIcon svg { width: 16px; height: 16px; stroke-width: 1.75; }\n.itemLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.destructive, .destructive .itemIcon { color: var(--danger); }\n.destructive[data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }\n@keyframes menu-exit { to { --menu-exit: 1; } }\n\n@media (prefers-reduced-motion: reduce) {\n  .content, .chevron, .segment::before, .highlight::before, .item { transition: none; }\n  .segment:active .content, .iconOnly:active .content { transform: none; }\n  .menu, .menu[data-state=\"closed\"] { transform: none; transition: opacity var(--duration-instant) linear; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/button-group","meta":{"tier":"free","kind":"component","tags":["action","group","toolbar","new"],"docs":"https://uiarc.dev/components/button-group","markdown":"https://uiarc.dev/components/button-group/markdown"}}