{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expanding-toggle-button",
  "title": "Expanding Toggle Button",
  "description": "A shadcn toggle button that expands from an icon into an icon-and-label action while keeping the icon anchored.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base/ui/expanding-toggle-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonProps = React.ComponentPropsWithoutRef<typeof Button>;\ntype ButtonClickEvent = Parameters<NonNullable<ButtonProps[\"onClick\"]>>[0];\ntype ExpandingToggleButtonSize = \"default\" | \"xs\" | \"sm\" | \"lg\";\n\nconst COLLAPSED_WIDTH: Record<ExpandingToggleButtonSize, number> = {\n  default: 32,\n  xs: 24,\n  sm: 28,\n  lg: 36,\n};\n\nconst ICON_SIZE: Record<ExpandingToggleButtonSize, number> = {\n  default: 16,\n  xs: 12,\n  sm: 14,\n  lg: 18,\n};\n\nconst LABEL_OUTER_PADDING: Record<ExpandingToggleButtonSize, number> = {\n  default: 10,\n  xs: 8,\n  sm: 10,\n  lg: 10,\n};\n\nconst LABEL_ICON_GAP = 6;\n\nconst MORPH_TRANSITION: Transition = {\n  duration: 0.2,\n  ease: [0.22, 1, 0.36, 1],\n};\n\nconst REDUCED_TRANSITION: Transition = { duration: 0 };\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nexport type ExpandingToggleButtonProps = Omit<\n  ButtonProps,\n  | \"aria-label\"\n  | \"aria-pressed\"\n  | \"children\"\n  | \"onClick\"\n  | \"size\"\n  | \"style\"\n> & {\n  /** Controlled active state. */\n  active?: boolean;\n  /** Initial active state for uncontrolled usage. */\n  defaultActive?: boolean;\n  /** Called after the button requests an active-state change. */\n  onActiveChange?: (active: boolean) => void;\n  /** Icon shown while the button is inactive. */\n  icon: React.ReactNode;\n  /** Optional icon shown while the button is active. */\n  activeIcon?: React.ReactNode;\n  /** Content revealed while the button is active. */\n  label: React.ReactNode;\n  /** Accessible name used while the button is inactive. */\n  inactiveLabel: string;\n  /** Accessible name used while the button is active. */\n  activeLabel: string;\n  /** Direction the label grows toward while the icon stays anchored. */\n  expandFrom?: \"start\" | \"end\";\n  /** Text-button size; the inactive width matches its icon-only counterpart. */\n  size?: ExpandingToggleButtonSize;\n  onClick?: (event: ButtonClickEvent) => void;\n};\n\nexport const ExpandingToggleButton = React.forwardRef<\n  HTMLButtonElement,\n  ExpandingToggleButtonProps\n>(function ExpandingToggleButton(\n  {\n    active,\n    defaultActive = false,\n    onActiveChange,\n    icon,\n    activeIcon,\n    label,\n    inactiveLabel,\n    activeLabel,\n    expandFrom = \"end\",\n    variant = \"outline\",\n    size = \"lg\",\n    disabled,\n    className,\n    onClick,\n    type = \"button\",\n    ...props\n  },\n  ref,\n) {\n  const [internalActive, setInternalActive] = React.useState(defaultActive);\n  const buttonRef = React.useRef<HTMLButtonElement | null>(null);\n  const contentRef = React.useRef<HTMLSpanElement | null>(null);\n  const hasMeasuredRef = React.useRef(false);\n  const shouldReduceMotion = useReducedMotion();\n  const controlled = active !== undefined;\n  const isActive = controlled ? active : internalActive;\n  const transition = shouldReduceMotion\n    ? REDUCED_TRANSITION\n    : MORPH_TRANSITION;\n  const resolvedSize = size ?? \"lg\";\n  const collapsedWidth = COLLAPSED_WIDTH[resolvedSize];\n  const iconSize = ICON_SIZE[resolvedSize];\n  const labelOuterPadding = LABEL_OUTER_PADDING[resolvedSize];\n  const iconReserve =\n    collapsedWidth / 2 + iconSize / 2 + LABEL_ICON_GAP;\n\n  const setButtonRef = React.useCallback(\n    (node: HTMLButtonElement | null) => {\n      buttonRef.current = node;\n\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref],\n  );\n\n  useIsomorphicLayoutEffect(() => {\n    const button = buttonRef.current;\n    const content = contentRef.current;\n\n    if (!button || !content) return;\n\n    const styles = getComputedStyle(button);\n    const expandedWidth = Math.ceil(\n      content.getBoundingClientRect().width +\n        Number.parseFloat(styles.paddingLeft) +\n        Number.parseFloat(styles.paddingRight) +\n        Number.parseFloat(styles.borderLeftWidth) +\n        Number.parseFloat(styles.borderRightWidth),\n    );\n    const targetWidth = isActive ? expandedWidth : collapsedWidth;\n\n    if (!hasMeasuredRef.current || shouldReduceMotion) {\n      button.style.width = `${targetWidth}px`;\n      hasMeasuredRef.current = true;\n      return;\n    }\n\n    const controls = animate(button, { width: targetWidth }, MORPH_TRANSITION);\n\n    return () => controls.stop();\n  }, [collapsedWidth, isActive, label, shouldReduceMotion]);\n\n  const handleClick = React.useCallback(\n    (event: ButtonClickEvent) => {\n      onClick?.(event);\n\n      if (event.defaultPrevented || disabled) return;\n\n      const nextActive = !isActive;\n\n      if (!controlled) {\n        setInternalActive(nextActive);\n      }\n\n      onActiveChange?.(nextActive);\n    }, [controlled, disabled, isActive, onActiveChange, onClick],\n  );\n\n  const labelSlot = (\n    <motion.span\n      aria-hidden={!isActive}\n      data-slot=\"expanding-toggle-button-label\"\n      initial={false}\n      animate={{ opacity: isActive ? 1 : 0 }}\n      transition={transition}\n      className=\"inline-flex shrink-0 items-center gap-1\"\n      style={\n        expandFrom === \"start\"\n          ? { paddingLeft: labelOuterPadding }\n          : { paddingRight: labelOuterPadding }\n      }\n    >\n      {label}\n    </motion.span>\n  );\n\n  const iconSpacer = (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"expanding-toggle-button-icon-spacer\"\n      className=\"h-full shrink-0\"\n      style={{ width: iconReserve }}\n    />\n  );\n\n  return (\n    <span\n      data-slot=\"expanding-toggle-button-anchor\"\n      className=\"relative inline-flex shrink-0 align-middle\"\n      style={{ width: collapsedWidth, height: collapsedWidth }}\n    >\n      <Button\n        ref={setButtonRef}\n        type={type}\n        size={resolvedSize}\n        variant={variant}\n        disabled={disabled}\n        aria-label={isActive ? activeLabel : inactiveLabel}\n        aria-pressed={isActive}\n        data-active={isActive}\n        data-slot=\"expanding-toggle-button\"\n        onClick={handleClick}\n        style={{ width: collapsedWidth }}\n        className={cn(\n          \"absolute top-0 gap-0! overflow-hidden p-0! transition-colors will-change-[width] motion-reduce:transition-none\",\n          expandFrom === \"start\" ? \"right-0 justify-end\" : \"left-0 justify-start\",\n          className,\n        )}\n        {...props}\n      >\n        <span\n          ref={contentRef}\n          className=\"inline-flex h-full shrink-0 items-center\"\n        >\n          {expandFrom === \"start\" ? labelSlot : iconSpacer}\n          {expandFrom === \"start\" ? iconSpacer : labelSlot}\n        </span>\n        <span\n          aria-hidden=\"true\"\n          data-icon={expandFrom === \"start\" ? \"inline-end\" : \"inline-start\"}\n          data-slot=\"expanding-toggle-button-icon\"\n          className={cn(\n            \"absolute inset-y-0 flex items-center justify-center\",\n            expandFrom === \"start\" ? \"-right-px\" : \"-left-px\",\n          )}\n          style={{ width: collapsedWidth }}\n        >\n          <span\n            className=\"relative block shrink-0\"\n            style={{ width: iconSize, height: iconSize }}\n          >\n            <motion.span\n              initial={false}\n              animate={{ opacity: isActive && activeIcon ? 0 : 1 }}\n              transition={transition}\n              className=\"absolute inset-0 flex items-center justify-center [&_svg]:size-full!\"\n            >\n              {icon}\n            </motion.span>\n            {activeIcon ? (\n              <motion.span\n                initial={false}\n                animate={{ opacity: isActive ? 1 : 0 }}\n                transition={transition}\n                className=\"absolute inset-0 flex items-center justify-center [&_svg]:size-full!\"\n              >\n                {activeIcon}\n              </motion.span>\n            ) : null}\n          </span>\n        </span>\n      </Button>\n    </span>\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/ui/expanding-toggle-button.tsx"
    }
  ],
  "meta": {
    "tags": [
      "button",
      "toggle",
      "icon",
      "controlled",
      "disclosure"
    ],
    "effects": [
      "width-animation",
      "icon-swap",
      "fade",
      "reduced-motion"
    ]
  },
  "categories": [
    "button"
  ],
  "type": "registry:ui"
}