{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"split-button","type":"registry:component","title":"Split button","description":"Dual-action trigger: primary action on the left, trailing chevron on the right opening an aligned dropdown menu.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/split-button.tsx","type":"registry:component","content":"// Hairline UI · split-button · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  INTERACTION_FOCUS,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\n\nexport interface SplitMenuItem {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  onSelect: () => void;\n  destructive?: boolean;\n  disabled?: boolean;\n}\n\nexport interface SplitButtonProps {\n  label: string;\n  icon?: React.ReactNode;\n  onClick?: () => void;\n  menuItems: SplitMenuItem[];\n  variant?: \"primary\" | \"secondary\" | \"destructive\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  disabled?: boolean;\n  loading?: boolean;\n  menuLabel?: string;\n  className?: string;\n}\n\n// Exact physics & timing from FilterBar\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst UI = \"cubic-bezier(0.23,1,0.32,1)\";\nconst OPEN = 280;\nconst CLOSE = 200;\nconst ROW_H = 32;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Box = { left: number; top: number; width: number; height: number };\n\nconst variantStyles = {\n  primary: {\n    container:\n      \"bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(48,93,222,0.18)] border-transparent\",\n    action: \"hover:bg-primary/90 active:opacity-95 text-primary-foreground\",\n    trigger: \"hover:bg-primary/90 active:opacity-95 text-primary-foreground\",\n    divider: \"bg-primary-foreground/25\",\n  },\n  secondary: {\n    container:\n      \"bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] border-transparent\",\n    action: \"hover:bg-accent active:bg-accent/80 text-foreground\",\n    trigger: \"hover:bg-accent active:bg-accent/80 text-foreground\",\n    divider: \"bg-border\",\n  },\n  destructive: {\n    container:\n      \"bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400 border-transparent\",\n    action: \"hover:bg-red-500/15 active:bg-red-500/20 text-red-600 dark:text-red-400\",\n    trigger: \"hover:bg-red-500/15 active:bg-red-500/20 text-red-600 dark:text-red-400\",\n    divider: \"bg-red-500/25\",\n  },\n};\n\nconst sizeStyles = {\n  sm: {\n    height: \"h-7 text-[12px] rounded-full\",\n    actionPad: \"pl-3 pr-2 gap-1.5\",\n    triggerPad: \"px-2\",\n    iconSize: 13,\n  },\n  md: {\n    height: \"h-9 text-[13px] rounded-full\",\n    actionPad: \"pl-4 pr-2.5 gap-2\",\n    triggerPad: \"px-2.5\",\n    iconSize: 14,\n  },\n  lg: {\n    height: \"h-10 text-[14px] rounded-full\",\n    actionPad: \"pl-5 pr-3 gap-2.5\",\n    triggerPad: \"px-3\",\n    iconSize: 16,\n  },\n};\n\nexport function SplitButton({\n  label,\n  icon,\n  onClick,\n  menuItems,\n  variant = \"primary\",\n  size = \"md\",\n  disabled = false,\n  loading = false,\n  menuLabel = \"More actions\",\n  className = \"\",\n}: SplitButtonProps) {\n  const reduced = useInteractionReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [box, setBox] = React.useState<Box>({ left: 0, top: 0, width: 0, height: 36 });\n  const [wrapW, setWrapW] = React.useState(200);\n  const [panelH, setPanelH] = React.useState(0);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [pressedAction, setPressedAction] = React.useState(false);\n  const [pressedTrigger, setPressedTrigger] = React.useState(false);\n\n  const wrapRef = React.useRef<HTMLDivElement | null>(null);\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const panelRef = React.useRef<HTMLDivElement | null>(null);\n  const timer = React.useRef<number>(0);\n\n  const v = variantStyles[variant];\n  const s = sizeStyles[size];\n  const inert = disabled || loading;\n  const mounted = phase !== \"closed\";\n  const expanded = phase === \"open\";\n\n  // Open the morphing floating dropdown from the trigger pill\n  const openMenu = () => {\n    const wrap = wrapRef.current;\n    const trigger = triggerRef.current;\n    if (!wrap || !trigger) return;\n    window.clearTimeout(timer.current);\n    const a = trigger.getBoundingClientRect();\n    const w = wrap.getBoundingClientRect();\n    setBox({\n      left: a.left - w.left,\n      top: a.top - w.top,\n      width: a.width,\n      height: a.height,\n    });\n    setWrapW(w.width);\n    setActiveIndex(0);\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const closeMenu = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) {\n        triggerRef.current?.focus({ preventScroll: true });\n      }\n      if (reduced) {\n        setPhase(\"closed\");\n        return;\n      }\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => {\n        setPhase(\"closed\");\n      }, CLOSE);\n    },\n    [reduced],\n  );\n\n  // FilterBar double-rAF expansion\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => setPhase(\"open\"));\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n\n  // Clean timer on unmount\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Measure content height dynamically\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    const measure = () => setPanelH(panel.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(panel);\n    return () => observer.disconnect();\n  }, [menuItems, mounted]);\n\n  // Close on outside pointer click\n  React.useEffect(() => {\n    if (!mounted || phase === \"closing\") return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (!wrapRef.current?.contains(e.target as Node)) {\n        closeMenu(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [mounted, phase, closeMenu]);\n\n  // Keyboard navigation\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!mounted || phase === \"closing\") {\n      if (e.key === \"ArrowDown\" && document.activeElement === triggerRef.current) {\n        e.preventDefault();\n        openMenu();\n      }\n      return;\n    }\n\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      closeMenu(true);\n    } else if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setActiveIndex((prev) => (prev < menuItems.length - 1 ? prev + 1 : 0));\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActiveIndex((prev) => (prev > 0 ? prev - 1 : menuItems.length - 1));\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      setActiveIndex(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      setActiveIndex(menuItems.length - 1);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      const current = menuItems[activeIndex];\n      if (current && !current.disabled) {\n        closeMenu(true);\n        current.onSelect();\n      }\n    }\n  };\n\n  const handleActionClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n    if (inert) {\n      e.preventDefault();\n      return;\n    }\n    onClick?.();\n  };\n\n  const toggleDropdown = (e: React.MouseEvent<HTMLButtonElement>) => {\n    e.stopPropagation();\n    if (inert) return;\n    if (mounted && phase !== \"closing\") {\n      closeMenu();\n    } else {\n      openMenu();\n    }\n  };\n\n  // Dimensions & alignment: matches FilterBar's floating panel geometry\n  const PANEL_W = 184;\n  const menuWidth = Math.min(PANEL_W, Math.max(160, wrapW));\n  const targetLeft = Math.max(0, box.left + box.width - menuWidth);\n  const targetTop = box.top + box.height + 6;\n\n  return (\n    <div\n      ref={wrapRef}\n      onKeyDown={handleKeyDown}\n      className={`relative inline-flex items-stretch select-none isolate ${s.height} ${v.container} ${\n        disabled ? \"opacity-45 cursor-not-allowed\" : \"\"\n      } ${className}`}\n    >\n      {/* Primary Action Button */}\n      <button\n        type=\"button\"\n        disabled={inert}\n        onClick={handleActionClick}\n        onPointerDown={() => !inert && setPressedAction(true)}\n        onPointerUp={() => setPressedAction(false)}\n        onPointerLeave={() => setPressedAction(false)}\n        style={{\n          transform: pressedAction ? \"scale(0.97)\" : undefined,\n          transition: reduced ? \"none\" : `transform 140ms ${EASE}`,\n        }}\n        className={`relative inline-flex items-center justify-center font-medium rounded-l-full transition-colors cursor-pointer disabled:cursor-not-allowed ${s.actionPad} ${v.action} ${INTERACTION_FOCUS}`}\n      >\n        {loading ? (\n          <span className=\"inline-flex size-3 animate-spin rounded-full border-[1.5px] border-current border-r-transparent mr-1.5\" />\n        ) : (\n          icon && <span className=\"inline-flex shrink-0 mr-1.5\">{icon}</span>\n        )}\n        <span className=\"whitespace-nowrap\">{label}</span>\n      </button>\n\n      {/* Quiet Hairline Divider */}\n      <span aria-hidden=\"true\" className={`w-px my-1.5 self-stretch ${v.divider}`} />\n\n      {/* Dropdown Chevron Trigger Button */}\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        disabled={inert}\n        onClick={toggleDropdown}\n        onPointerDown={() => !inert && setPressedTrigger(true)}\n        onPointerUp={() => setPressedTrigger(false)}\n        onPointerLeave={() => setPressedTrigger(false)}\n        aria-label={menuLabel}\n        aria-haspopup=\"menu\"\n        aria-expanded={mounted && phase !== \"closing\"}\n        style={{\n          transform: pressedTrigger ? \"scale(0.97)\" : undefined,\n          transition: reduced ? \"none\" : `transform 140ms ${EASE}`,\n        }}\n        className={`relative inline-flex items-center justify-center rounded-r-full cursor-pointer transition-colors disabled:cursor-not-allowed ${s.triggerPad} ${v.trigger} ${INTERACTION_FOCUS}`}\n      >\n        <ChevronDown\n          size={s.iconSize}\n          strokeWidth={1.75}\n          style={{\n            transform: mounted && phase !== \"closing\" ? \"rotate(180deg)\" : \"none\",\n            transition: reduced ? \"none\" : `transform 300ms ${EASE}`,\n          }}\n        />\n      </button>\n\n      {/* Floating Panel: Morphs out from under the trigger pill with HairlineUI physics */}\n      {mounted && (\n        <div\n          role=\"menu\"\n          aria-label={menuLabel}\n          className=\"absolute z-50 overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            top: targetTop,\n            left: expanded ? targetLeft : box.left,\n            width: expanded ? menuWidth : box.width,\n            height: expanded ? panelH : box.height,\n            borderRadius: expanded ? 12 : box.height / 2,\n            opacity: expanded ? 1 : 0,\n            boxShadow: \"0 0 0 1px var(--border)\",\n            transition: reduced\n              ? \"none\"\n              : phase === \"closing\"\n                ? `width ${CLOSE}ms ${UI}, height ${CLOSE}ms ${UI}, left ${CLOSE}ms ${UI}, border-radius ${CLOSE}ms ${UI}, opacity ${CLOSE}ms ${UI}`\n                : `width ${OPEN}ms ${UI}, height ${OPEN}ms ${UI}, left ${OPEN}ms ${UI}, border-radius ${OPEN}ms ${UI}, opacity 160ms ${UI}`,\n          }}\n        >\n          <div\n            ref={panelRef}\n            inert={!expanded ? true : undefined}\n            className=\"relative p-1\"\n            style={{\n              width: menuWidth,\n              opacity: expanded ? 1 : 0,\n              transition: reduced\n                ? \"none\"\n                : expanded\n                  ? `opacity 180ms ${EASE} 60ms`\n                  : \"opacity 90ms ease-out\",\n            }}\n          >\n            {/* The Internal Highlight Glides Between Menu Rows */}\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-1 top-1 h-8 rounded-[7px] bg-accent\"\n              style={{\n                transform: `translateY(${activeIndex * ROW_H}px)`,\n                opacity: activeIndex >= 0 && menuItems.length > 0 ? 1 : 0,\n                transition: reduced ? \"none\" : `transform 160ms ${UI}`,\n              }}\n            />\n\n            {/* Menu Items */}\n            {menuItems.map((item, index) => {\n              const isDestructive = item.destructive;\n              const isSelected = activeIndex === index;\n\n              return (\n                <button\n                  key={item.id}\n                  type=\"button\"\n                  role=\"menuitem\"\n                  data-menu-item=\"\"\n                  disabled={item.disabled}\n                  onPointerEnter={() => !item.disabled && setActiveIndex(index)}\n                  onClick={() => {\n                    closeMenu(false);\n                    item.onSelect();\n                  }}\n                  className={`relative z-10 flex h-8 w-full items-center gap-2 rounded-[7px] px-2.5 text-[13px] font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 select-none text-left ${\n                    isDestructive\n                      ? isSelected\n                        ? \"text-red-600 dark:text-red-400 font-semibold\"\n                        : \"text-red-500/90 dark:text-red-400/90\"\n                      : isSelected\n                        ? \"text-foreground font-semibold\"\n                        : \"text-muted-foreground\"\n                  } ${INTERACTION_FOCUS}`}\n                >\n                  {item.icon && (\n                    <span\n                      className={`inline-flex size-3.5 shrink-0 items-center justify-center ${\n                        isDestructive ? \"text-red-500\" : \"text-muted-foreground\"\n                      }`}\n                    >\n                      {item.icon}\n                    </span>\n                  )}\n                  <span className=\"truncate flex-1\">{item.label}</span>\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default SplitButton;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/split-button. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}