{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button-group","type":"registry:component","title":"Button group","description":"Connected actions joined into one surface: hairline dividers, a gliding hover highlight, and responsive auto-collapsing labels.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/button-group.tsx","type":"registry:component","content":"// Hairline UI · button-group · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS } from \"./ui-interaction-motion\";\n\nexport type ButtonGroupVariant = \"outline\" | \"solid\";\nexport type ButtonGroupSize = \"sm\" | \"md\";\nexport type ButtonGroupOrientation = \"horizontal\" | \"vertical\";\n\nexport interface ButtonGroupItem {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  iconOnly?: boolean;\n  content?: React.ReactNode;\n  onClick?: () => void;\n  href?: string;\n  disabled?: boolean;\n  active?: boolean;\n}\n\nexport interface ButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n  items: ButtonGroupItem[];\n  variant?: ButtonGroupVariant;\n  size?: ButtonGroupSize;\n  orientation?: ButtonGroupOrientation;\n  collapseLabels?: boolean;\n  label?: string;\n}\n\nexport function ButtonGroup({\n  items,\n  variant = \"outline\",\n  size = \"md\",\n  orientation = \"horizontal\",\n  collapseLabels = true,\n  label = \"Action group\",\n  className = \"\",\n  style,\n  ...props\n}: ButtonGroupProps) {\n  const rootRef = React.useRef<HTMLDivElement | null>(null);\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null);\n  const [focusedId, setFocusedId] = React.useState<string | null>(null);\n  const [isCompact, setIsCompact] = React.useState(false);\n\n  // Position of the gliding highlight\n  const [highlightRect, setHighlightRect] = React.useState<{\n    x: number;\n    y: number;\n    width: number;\n    height: number;\n    visible: boolean;\n  }>({ x: 0, y: 0, width: 0, height: 0, visible: false });\n\n  const activeId = hoveredId ?? focusedId;\n  const isVertical = orientation === \"vertical\";\n\n  // Measure active element to glide highlight\n  React.useLayoutEffect(() => {\n    if (!rootRef.current || !activeId) {\n      setHighlightRect((prev) => ({ ...prev, visible: false }));\n      return;\n    }\n\n    const activeEl = rootRef.current.querySelector<HTMLElement>(`[data-group-item=\"${activeId}\"]`);\n    if (!activeEl) {\n      setHighlightRect((prev) => ({ ...prev, visible: false }));\n      return;\n    }\n\n    const rootRect = rootRef.current.getBoundingClientRect();\n    const itemRect = activeEl.getBoundingClientRect();\n\n    setHighlightRect({\n      x: itemRect.left - rootRect.left,\n      y: itemRect.top - rootRect.top,\n      width: itemRect.width,\n      height: itemRect.height,\n      visible: true,\n    });\n  }, [activeId, isCompact, size, items, isVertical]);\n\n  // Responsive container observer: if parent width is tight, collapse text labels to icons\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root || !collapseLabels || isVertical) return;\n\n    const checkSpace = () => {\n      const parent = root.parentElement;\n      const available = parent ? parent.clientWidth : window.innerWidth;\n      if (available < 340 || (parent && root.scrollWidth > available - 16)) {\n        setIsCompact(true);\n      } else if (available >= 380) {\n        setIsCompact(false);\n      }\n    };\n\n    checkSpace();\n    const ro = new ResizeObserver(checkSpace);\n    if (root.parentElement) ro.observe(root.parentElement);\n    ro.observe(root);\n    window.addEventListener(\"resize\", checkSpace);\n\n    return () => {\n      ro.disconnect();\n      window.removeEventListener(\"resize\", checkSpace);\n    };\n  }, [collapseLabels, isVertical, items]);\n\n  // Keyboard navigation across segments\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const list = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(\"[data-group-item]\") ?? []);\n    const currentIndex = list.findIndex((node) => node === document.activeElement);\n    if (currentIndex < 0) return;\n\n    let nextIndex = -1;\n    if (isVertical) {\n      if (e.key === \"ArrowDown\") nextIndex = currentIndex < list.length - 1 ? currentIndex + 1 : 0;\n      else if (e.key === \"ArrowUp\") nextIndex = currentIndex > 0 ? currentIndex - 1 : list.length - 1;\n    } else {\n      if (e.key === \"ArrowRight\") nextIndex = currentIndex < list.length - 1 ? currentIndex + 1 : 0;\n      else if (e.key === \"ArrowLeft\") nextIndex = currentIndex > 0 ? currentIndex - 1 : list.length - 1;\n    }\n\n    if (e.key === \"Home\") nextIndex = 0;\n    if (e.key === \"End\") nextIndex = list.length - 1;\n\n    if (nextIndex >= 0) {\n      e.preventDefault();\n      list[nextIndex]?.focus();\n      setFocusedId(items[nextIndex]?.id ?? null);\n    }\n  };\n\n  const heightClass = size === \"sm\" ? \"h-7 text-[12px]\" : \"h-8.5 text-[13px]\";\n  const paddingClass = size === \"sm\" ? \"px-3\" : \"px-4\";\n  const iconOnlyWidth = size === \"sm\" ? \"w-7\" : \"w-8.5\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      onKeyDown={handleKeyDown}\n      style={style}\n      className={`relative inline-flex max-w-full select-none isolate shrink-0 ${\n        isVertical\n          ? \"flex-col rounded-[12px] p-1 gap-0.5 min-w-[180px] w-48 shadow-[inset_0_0_0_1px_var(--border)] bg-card text-foreground\"\n          : `flex-row items-stretch rounded-full overflow-hidden ${\n              variant === \"solid\"\n                ? \"bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(48,93,222,0.18)]\"\n                : \"bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n            }`\n      } ${className}`}\n      {...props}\n    >\n      {/* Gliding Highlight */}\n      <span\n        aria-hidden=\"true\"\n        className={`absolute pointer-events-none z-0 transition-all duration-200 motion-reduce:transition-none ${\n          isVertical\n            ? \"rounded-[8px] bg-muted\"\n            : variant === \"solid\"\n            ? \"rounded-full bg-primary-foreground/20\"\n            : \"rounded-full bg-muted\"\n        }`}\n        style={{\n          transform: `translate3d(${highlightRect.x}px, ${highlightRect.y}px, 0)`,\n          width: highlightRect.width ? `${highlightRect.width}px` : 0,\n          height: highlightRect.height ? `${highlightRect.height}px` : 0,\n          opacity: highlightRect.visible ? 1 : 0,\n          transition: `transform 200ms ${EASE_SETTLE}, width 200ms ${EASE_SETTLE}, height 200ms ${EASE_SETTLE}, opacity 160ms ${EASE_OUT}`,\n        }}\n      />\n\n      {items.map((item, index) => {\n        const isHovered = hoveredId === item.id;\n        const isNeighborHovered =\n          (index > 0 && items[index - 1]?.id === hoveredId) ||\n          (index < items.length - 1 && items[index + 1]?.id === hoveredId);\n        const isActive = !!item.active;\n        const isNeighborActive =\n          (index > 0 && !!items[index - 1]?.active) ||\n          (index < items.length - 1 && !!items[index + 1]?.active);\n\n        // Hide divider if either segment is hovered or active\n        const hideDivider = isHovered || isNeighborHovered || isActive || isNeighborActive;\n        const showIconOnly = !isVertical && (item.iconOnly || (isCompact && !!item.icon));\n\n        const content = (\n          <span className=\"relative z-10 inline-flex items-center gap-2 font-medium\">\n            {item.icon && <span className=\"inline-flex shrink-0 items-center justify-center text-muted-foreground\">{item.icon}</span>}\n            {item.content !== undefined ? (\n              item.content\n            ) : !showIconOnly ? (\n              <span className=\"truncate\">{item.label}</span>\n            ) : null}\n          </span>\n        );\n\n        const activeStyles = isActive\n          ? isVertical\n            ? \"bg-muted text-foreground font-semibold\"\n            : variant === \"solid\"\n            ? \"bg-primary-foreground/25 text-primary-foreground font-semibold\"\n            : \"bg-muted text-foreground font-semibold\"\n          : \"\";\n\n        const itemClasses = isVertical\n          ? `relative z-10 flex w-full items-center justify-start cursor-pointer font-medium rounded-[8px] h-8 px-2.5 text-[13px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none ${activeStyles} ${INTERACTION_FOCUS}`\n          : `relative z-10 inline-flex items-center justify-center cursor-pointer font-medium rounded-full transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none ${heightClass} ${\n              showIconOnly ? `${iconOnlyWidth} px-0 justify-center` : paddingClass\n            } ${activeStyles} ${INTERACTION_FOCUS}`;\n\n        const handlePointerEnter = () => {\n          if (!item.disabled) setHoveredId(item.id);\n        };\n        const handlePointerLeave = () => {\n          setHoveredId((current) => (current === item.id ? null : current));\n        };\n        const handleFocus = () => {\n          if (!item.disabled) setFocusedId(item.id);\n        };\n        const handleBlur = () => {\n          setFocusedId((current) => (current === item.id ? null : current));\n        };\n\n        return (\n          <React.Fragment key={item.id}>\n            {/* Hairline Divider between horizontal segments */}\n            {!isVertical && index > 0 && (\n              <span\n                aria-hidden=\"true\"\n                className={`transition-opacity duration-150 shrink-0 w-px h-[50%] self-center ${\n                  variant === \"solid\" ? \"bg-primary-foreground/25\" : \"bg-border\"\n                } ${hideDivider ? \"opacity-0\" : \"opacity-100\"}`}\n              />\n            )}\n\n            {item.href ? (\n              <a\n                href={item.href}\n                data-group-item={item.id}\n                title={showIconOnly ? item.label : undefined}\n                aria-label={item.label}\n                onPointerEnter={handlePointerEnter}\n                onPointerLeave={handlePointerLeave}\n                onFocus={handleFocus}\n                onBlur={handleBlur}\n                onClick={item.onClick}\n                className={itemClasses}\n              >\n                {content}\n              </a>\n            ) : (\n              <button\n                type=\"button\"\n                data-group-item={item.id}\n                disabled={item.disabled}\n                title={showIconOnly ? item.label : undefined}\n                aria-label={item.label}\n                onPointerEnter={handlePointerEnter}\n                onPointerLeave={handlePointerLeave}\n                onFocus={handleFocus}\n                onBlur={handleBlur}\n                onClick={item.onClick}\n                className={itemClasses}\n              >\n                {content}\n              </button>\n            )}\n          </React.Fragment>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default ButtonGroup;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/button-group. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}