{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"expanding-button-group","type":"registry:component","title":"Expanding button group","description":"Compact icon toolbar where the hovered or focused action grows to reveal its label while neighbours slide aside, with in-place confirmation.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/expanding-button-group.tsx","type":"registry:component","content":"// Hairline UI · expanding-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 {\n  EASE_OUT,\n  INTERACTION_FOCUS,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\n\nexport type ExpandingItemTone = \"default\" | \"primary\" | \"danger\";\n\nexport interface ExpandingButtonItem {\n  id: string;\n  label: string;\n  icon: React.ReactNode;\n  tone?: ExpandingItemTone;\n  confirmLabel?: string;\n  onClick?: () => void;\n  disabled?: boolean;\n}\n\nexport interface ExpandingButtonGroupProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  items: ExpandingButtonItem[];\n  /** Controlled active/expanded item id. If not provided, hover/focus controls expansion. */\n  expandedId?: string | null;\n  /** Initial item to be expanded. */\n  defaultExpandedId?: string | null;\n  /** Visual density. Defaults to md. */\n  size?: \"sm\" | \"md\";\n  /** Aria label for the toolbar group. */\n  label?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\nfunction InlineCheck({ size }: { size: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" className=\"shrink-0\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nconst toneStyles = {\n  default: {\n    idle: \"text-muted-foreground hover:text-foreground hover:bg-muted/70\",\n    expanded: \"bg-muted text-foreground font-semibold\",\n    activePress: \"scale-[0.96]\",\n  },\n  primary: {\n    idle: \"text-muted-foreground hover:text-foreground hover:bg-muted/70\",\n    expanded: \"bg-primary text-primary-foreground font-semibold shadow-[0_1px_2px_rgba(48,93,222,0.18)]\",\n    activePress: \"scale-[0.96]\",\n  },\n  danger: {\n    idle: \"text-muted-foreground hover:text-red-600 hover:bg-red-500/10 dark:hover:text-red-400\",\n    expanded: \"bg-red-500/10 text-red-600 dark:text-red-400 font-semibold shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_25%,transparent)]\",\n    activePress: \"scale-[0.96]\",\n  },\n};\n\nexport function ExpandingButtonGroup({\n  items,\n  expandedId: controlledExpandedId,\n  defaultExpandedId = null,\n  size = \"md\",\n  label = \"Action toolbar\",\n  className = \"\",\n  style,\n  ...props\n}: ExpandingButtonGroupProps) {\n  const reduced = useInteractionReducedMotion();\n  const [internalExpandedId, setInternalExpandedId] = React.useState<string | null>(\n    defaultExpandedId\n  );\n  const [confirmedId, setConfirmedId] = React.useState<string | null>(null);\n  const [pressedId, setPressedId] = React.useState<string | null>(null);\n  const groupRef = React.useRef<HTMLDivElement | null>(null);\n  const confirmTimer = React.useRef<number>(0);\n\n  const activeId = controlledExpandedId !== undefined ? controlledExpandedId : internalExpandedId;\n\n  // Clean timer on unmount\n  React.useEffect(() => () => window.clearTimeout(confirmTimer.current), []);\n\n  const handlePointerEnter = (id: string, disabled?: boolean) => {\n    if (disabled) return;\n    setInternalExpandedId(id);\n  };\n\n  const handlePointerLeave = () => {\n    if (controlledExpandedId === undefined) {\n      setInternalExpandedId(null);\n    }\n  };\n\n  const handleFocus = (id: string, disabled?: boolean) => {\n    if (disabled) return;\n    setInternalExpandedId(id);\n  };\n\n  const handleBlur = (e: React.FocusEvent) => {\n    if (groupRef.current?.contains(e.relatedTarget as Node)) return;\n    if (controlledExpandedId === undefined) {\n      setInternalExpandedId(null);\n    }\n  };\n\n  const handleClick = (item: ExpandingButtonItem) => {\n    if (item.disabled) return;\n\n    if (item.confirmLabel) {\n      setConfirmedId(item.id);\n      window.clearTimeout(confirmTimer.current);\n      confirmTimer.current = window.setTimeout(() => {\n        setConfirmedId(null);\n      }, 1600);\n    }\n\n    item.onClick?.();\n  };\n\n  // Keyboard navigation across items\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const buttons = Array.from(\n      groupRef.current?.querySelectorAll<HTMLButtonElement>(\"button[data-expanding-item]\") ?? []\n    );\n    const currentIndex = buttons.findIndex((btn) => btn === document.activeElement);\n    if (currentIndex < 0) return;\n\n    let nextIndex = -1;\n    if (e.key === \"ArrowRight\") {\n      nextIndex = currentIndex < buttons.length - 1 ? currentIndex + 1 : 0;\n    } else if (e.key === \"ArrowLeft\") {\n      nextIndex = currentIndex > 0 ? currentIndex - 1 : buttons.length - 1;\n    } else if (e.key === \"Home\") {\n      nextIndex = 0;\n    } else if (e.key === \"End\") {\n      nextIndex = buttons.length - 1;\n    }\n\n    if (nextIndex >= 0) {\n      e.preventDefault();\n      const targetBtn = buttons[nextIndex];\n      targetBtn?.focus();\n      const nextItem = items[nextIndex];\n      if (nextItem && !nextItem.disabled) {\n        setInternalExpandedId(nextItem.id);\n      }\n    }\n  };\n\n  const heightClass = size === \"sm\" ? \"h-7\" : \"h-8.5\";\n  const iconBoxSize = size === \"sm\" ? \"size-7\" : \"size-8.5\";\n  const iconSize = size === \"sm\" ? 13 : 14;\n\n  return (\n    <div\n      ref={groupRef}\n      role=\"toolbar\"\n      aria-label={label}\n      onKeyDown={handleKeyDown}\n      onPointerLeave={handlePointerLeave}\n      onBlur={handleBlur}\n      style={style}\n      className={`relative inline-flex items-center rounded-full p-1 gap-1 select-none isolate bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] max-w-full overflow-x-auto ${className}`}\n      {...props}\n    >\n      {items.map((item) => {\n        const isExpanded = activeId === item.id;\n        const isConfirmed = confirmedId === item.id;\n        const isPressed = pressedId === item.id;\n        const tone = item.tone ?? \"default\";\n        const toneStyle = toneStyles[tone];\n\n        const displayLabel = isConfirmed\n          ? item.confirmLabel ?? \"Done!\"\n          : item.label;\n\n        return (\n          <button\n            key={item.id}\n            type=\"button\"\n            data-expanding-item=\"\"\n            disabled={item.disabled}\n            aria-label={item.label}\n            onPointerEnter={() => handlePointerEnter(item.id, item.disabled)}\n            onFocus={() => handleFocus(item.id, item.disabled)}\n            onClick={() => handleClick(item)}\n            onPointerDown={() => !item.disabled && setPressedId(item.id)}\n            onPointerUp={() => setPressedId(null)}\n            style={{\n              transform: isPressed ? \"scale(0.96)\" : undefined,\n              transition: reduced\n                ? \"none\"\n                : `transform 140ms ${EASE}, background-color 160ms ${EASE}, color 160ms ${EASE}`,\n            }}\n            className={`relative flex items-center justify-center rounded-full cursor-pointer transition-colors duration-160 select-none disabled:cursor-not-allowed disabled:opacity-40 ${heightClass} ${\n              isExpanded ? \"px-2.5\" : `${iconBoxSize} px-0`\n            } ${isExpanded ? toneStyle.expanded : toneStyle.idle} ${INTERACTION_FOCUS}`}\n          >\n            {/* Leading Icon Slot */}\n            <span\n              className={`relative inline-flex shrink-0 items-center justify-center transition-transform duration-160 ${\n                isConfirmed ? \"text-emerald-500 scale-105\" : \"\"\n              }`}\n              style={{\n                width: iconSize,\n                height: iconSize,\n              }}\n            >\n              {isConfirmed ? <InlineCheck size={iconSize} /> : item.icon}\n            </span>\n\n            {/* Expanding Text Label with CSS Grid Smooth Layout Transition */}\n            <span\n              className=\"grid overflow-hidden whitespace-nowrap text-[12.5px] font-medium transition-all\"\n              style={{\n                gridTemplateColumns: isExpanded ? \"1fr\" : \"0fr\",\n                opacity: isExpanded ? 1 : 0,\n                transition: reduced\n                  ? \"none\"\n                  : `grid-template-columns 240ms ${EASE}, opacity 180ms ${EASE}`,\n              }}\n            >\n              <span className=\"min-w-0 overflow-hidden pl-1.5 pr-0.5 font-medium truncate\">\n                {displayLabel}\n              </span>\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default ExpandingButtonGroup;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/expanding-button-group. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}