{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-button",
  "type": "registry:ui",
  "title": "Split Button",
  "description": "Main action plus a chevron menu; the choice can become the main action.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/button.json"
  ],
  "files": [
    {
      "path": "components/ui/split-button.tsx",
      "content": "\"use client\";\nimport { Button as BaseButton } from \"@base-ui/react/button\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useRef } from \"react\";\nimport { Spinner, useBusyDisplay } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SplitButtonAction = {\n  value: string;\n  label: string;\n  /** A second line in the menu: what this choice does differently. */\n  description?: string;\n  icon?: React.ReactNode;\n  /** Keys shown at the end of the menu row, space-separated: \"⌘ ⇧ S\". */\n  shortcut?: string;\n  disabled?: boolean;\n  /** Destructive: shown in the danger color, after a separator. */\n  danger?: boolean;\n  /** Runs when the row is chosen (menu mode only). */\n  onSelect?: () => void;\n};\n\nexport type SplitButtonProps = Omit<React.ComponentProps<\"div\">, \"onClick\" | \"defaultValue\"> & {\n  /** The main action's label. In select mode the chosen action's label is shown instead. */\n  children?: React.ReactNode;\n  /** Icon before the main label. */\n  icon?: React.ReactNode;\n  actions: SplitButtonAction[];\n  /** Runs the main action. In select mode it receives the chosen action's value. */\n  onClick?: (value?: string) => void;\n  /**\n   * Select mode: choosing a menu row makes it the main action (the label morphs\n   * in place) instead of running it. Turned on by passing value or defaultValue.\n   */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  variant?: \"primary\" | \"secondary\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  loading?: boolean;\n  disabled?: boolean;\n  /** Accessible name of the chevron. */\n  menuLabel?: string;\n  /** Where the menu renders; defaults to the body. */\n  container?: HTMLElement | null;\n};\n\nconst sizes = {\n  sm: { h: \"h-7 text-[12px] [&_svg]:size-3.5\", main: \"px-2 has-[[data-icon]]:pl-1.5 rounded-s-md\", chevron: \"w-6 rounded-e-md\", radius: \"rounded-md\" },\n  md: { h: \"h-8 text-[12.5px] [&_svg]:size-4\", main: \"px-2.5 has-[[data-icon]]:pl-2 rounded-s-lg\", chevron: \"w-7 rounded-e-lg\", radius: \"rounded-lg\" },\n  lg: { h: \"h-9 text-[13px] [&_svg]:size-4\", main: \"px-3 has-[[data-icon]]:pl-2.5 rounded-s-lg\", chevron: \"w-8 rounded-e-lg\", radius: \"rounded-lg\" },\n};\n\nconst looks = {\n  primary: {\n    part: \"bg-fg text-frame hover:bg-fg/90 data-popup-open:bg-fg/85\",\n    divider: \"bg-frame/20\",\n  },\n  secondary: {\n    part: \"border border-line-2 bg-raised text-fg hover:bg-hover data-popup-open:bg-hover\",\n    divider: \"bg-line-2\",\n  },\n};\n\nconst item =\n  \"group/item relative flex cursor-default select-none items-start gap-2.5 rounded-lg px-2 py-[7px] text-[13px] leading-[18px] outline-none data-disabled:opacity-45\";\n\n/**\n * A main action with its alternatives one press away. The chevron opens a menu\n * that grows from it; in select mode the choice becomes the main action.\n */\nexport function SplitButton({\n  children,\n  icon,\n  actions,\n  onClick,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"primary\",\n  size = \"md\",\n  loading = false,\n  disabled = false,\n  menuLabel = \"More options\",\n  container,\n  className,\n  ...rest\n}: SplitButtonProps) {\n  const reduce = useReducedMotion();\n  const selectable = value !== undefined || defaultValue !== undefined;\n  const [selected, setSelected] = useControllableState({ value, defaultValue: defaultValue ?? actions[0]?.value ?? \"\", onChange: onValueChange });\n  const current = selectable ? actions.find((a) => a.value === selected) : undefined;\n  const shown = useBusyDisplay(loading);\n  const busy = loading || shown;\n  const groupRef = useRef<HTMLDivElement>(null);\n  const mainRef = useRef<HTMLButtonElement>(null);\n  const chose = useRef(false);\n  const s = sizes[size];\n  const look = looks[variant];\n  const mainIcon = current?.icon ?? icon;\n  const regular = actions.filter((a) => !a.danger);\n  const dangerous = actions.filter((a) => a.danger);\n\n  const part = cn(\n    \"relative inline-flex shrink-0 select-none items-center justify-center font-medium tracking-[-0.005em] outline-none\",\n    \"touch-manipulation [-webkit-tap-highlight-color:transparent] focus-visible:z-10\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    \"transition-[background-color,border-color,color] duration-150 ease-out-quart\",\n    \"data-disabled:pointer-events-none\",\n    s.h,\n    look.part,\n  );\n\n  // Every label in one grid cell: the button is as wide as the longest choice, so\n  // switching never moves anything around it.\n  const labels = selectable ? actions.map((a) => a.label) : [];\n  const swapIn = reduce ? { opacity: 0 } : { opacity: 0, y: 7, filter: \"blur(3px)\" };\n  const swapOut = reduce ? { opacity: 0 } : { opacity: 0, y: -7, filter: \"blur(3px)\" };\n  const here = { opacity: 1, y: 0, filter: \"blur(0px)\" };\n\n  return (\n    <div\n      ref={groupRef}\n      role=\"group\"\n      data-variant={variant}\n      data-size={size}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"inline-flex shrink-0 items-stretch shadow-[var(--shadow)] transition-[scale,opacity] duration-150 ease-out-quart\",\n        // One object: pressing either half pushes the whole thing, so the seam never opens.\n        \"has-[button:active:not([data-disabled])]:scale-[0.98] has-[button:active]:duration-75\",\n        disabled && \"opacity-50\",\n        s.radius,\n        className,\n      )}\n      {...rest}\n    >\n      <BaseButton\n        ref={mainRef}\n        disabled={disabled || busy}\n        focusableWhenDisabled={busy && !disabled}\n        aria-busy={busy || undefined}\n        data-busy={busy ? \"\" : undefined}\n        onClick={() => onClick?.(current?.value)}\n        className={cn(part, s.main, \"data-busy:cursor-progress\", variant === \"secondary\" && \"border-e-0\")}\n      >\n        <span className=\"grid place-items-center\">\n          <motion.span\n            className=\"col-start-1 row-start-1 inline-flex items-center gap-1.5\"\n            initial={false}\n            animate={shown ? swapOut : here}\n            transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n          >\n            {mainIcon && (\n              <span data-icon aria-hidden className=\"relative grid size-4 shrink-0 place-items-center [&>*]:col-start-1 [&>*]:row-start-1\">\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={current?.value ?? \"icon\"}\n                    className=\"grid place-items-center\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(3px)\" }}\n                    animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(3px)\" }}\n                    transition={reduce ? { duration: 0.15 } : spring.pop}\n                  >\n                    {mainIcon}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            )}\n            {selectable ? (\n              <span className=\"grid overflow-hidden py-1 text-left\">\n                {labels.map((l) => (\n                  <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n                    {l}\n                  </span>\n                ))}\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={current?.value}\n                    className=\"col-start-1 row-start-1 whitespace-nowrap\"\n                    initial={swapIn}\n                    animate={here}\n                    exit={swapOut}\n                    transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n                  >\n                    {current?.label}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            ) : (\n              <span className=\"whitespace-nowrap\">{children}</span>\n            )}\n          </motion.span>\n          <AnimatePresence initial={false}>\n            {shown && (\n              <motion.span\n                key=\"busy\"\n                className=\"col-start-1 row-start-1 grid place-items-center\"\n                initial={swapIn}\n                animate={here}\n                exit={{ ...swapIn, transition: { duration: 0.15 } }}\n                transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n              >\n                <Spinner />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </BaseButton>\n\n      <span aria-hidden className={cn(\"w-px shrink-0\", look.divider)} />\n\n      <Menu.Root modal={false} disabled={disabled || busy}>\n        <Menu.Trigger aria-label={menuLabel} className={cn(part, s.chevron, \"group/chevron\", variant === \"secondary\" && \"border-s-0\")}>\n          <svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.4}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n            className=\"transition-transform duration-200 ease-out-expo group-data-popup-open/chevron:rotate-180 motion-reduce:transition-none\"\n          >\n            <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n          </svg>\n        </Menu.Trigger>\n        <Menu.Portal container={container}>\n          <Menu.Positioner anchor={groupRef} side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n            <Menu.Popup\n              finalFocus={() => {\n                // After choosing a new main action, focus lands on it so Enter runs it.\n                const next = chose.current ? mainRef.current : true;\n                chose.current = false;\n                return next;\n              }}\n              className={cn(\n                \"min-w-(--anchor-width) max-w-[min(320px,var(--available-width))] rounded-xl border border-line-2 bg-raised p-1 shadow-pop outline-none\",\n                \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n                \"data-starting-style:-translate-y-1 data-starting-style:scale-96 data-starting-style:opacity-0\",\n                \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n                \"data-instant:duration-0\",\n              )}\n            >\n              {selectable ? (\n                <Menu.RadioGroup\n                  value={selected}\n                  onValueChange={(v: string) => {\n                    chose.current = true;\n                    setSelected(v);\n                  }}\n                >\n                  {actions.map((a) => (\n                    <Menu.RadioItem key={a.value} value={a.value} label={a.label} disabled={a.disabled} closeOnClick className={cn(item, \"text-fg data-highlighted:bg-fg/[0.06]\")}>\n                      <span className=\"mt-px grid size-4 shrink-0 place-items-center text-fg\">\n                        <Menu.RadioItemIndicator keepMounted className=\"grid place-items-center\">\n                          <Tick />\n                        </Menu.RadioItemIndicator>\n                      </span>\n                      <Row action={a} />\n                    </Menu.RadioItem>\n                  ))}\n                </Menu.RadioGroup>\n              ) : (\n                <>\n                  {regular.map((a) => (\n                    <Menu.Item key={a.value} label={a.label} disabled={a.disabled} onClick={a.onSelect} className={cn(item, \"text-fg data-highlighted:bg-fg/[0.06]\")}>\n                      {a.icon && <span aria-hidden className=\"mt-px grid size-4 shrink-0 place-items-center text-fg-3 [&_svg]:size-4 group-data-highlighted/item:text-fg-2\">{a.icon}</span>}\n                      <Row action={a} />\n                    </Menu.Item>\n                  ))}\n                  {dangerous.length > 0 && regular.length > 0 && <Menu.Separator className=\"mx-2 my-1 h-px bg-line\" />}\n                  {dangerous.map((a) => (\n                    <Menu.Item\n                      key={a.value}\n                      label={a.label}\n                      disabled={a.disabled}\n                      onClick={a.onSelect}\n                      className={cn(item, \"text-danger data-highlighted:bg-danger-soft\")}\n                    >\n                      {a.icon && <span aria-hidden className=\"mt-px grid size-4 shrink-0 place-items-center [&_svg]:size-4\">{a.icon}</span>}\n                      <Row action={a} />\n                    </Menu.Item>\n                  ))}\n                </>\n              )}\n            </Menu.Popup>\n          </Menu.Positioner>\n        </Menu.Portal>\n      </Menu.Root>\n    </div>\n  );\n}\n\nfunction Row({ action }: { action: SplitButtonAction }) {\n  return (\n    <>\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"truncate\">{action.label}</span>\n        {action.description && <span className=\"text-[12px] leading-[17px] text-fg-3\">{action.description}</span>}\n      </span>\n      {action.shortcut && (\n        <kbd className=\"ml-3 mt-px flex shrink-0 gap-0.5 font-sans\">\n          {action.shortcut.split(\" \").map((k, i) => (\n            <span key={i} className=\"grid h-4 min-w-4 place-items-center rounded-[4px] border border-line-2 px-1 font-mono text-[10px] leading-none text-fg-3\">\n              {k}\n            </span>\n          ))}\n        </kbd>\n      )}\n    </>\n  );\n}\n\n// Draws itself when its row becomes the chosen one; unchosen rows keep it at zero length.\nfunction Tick() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        pathLength={1}\n        className=\"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] duration-300 ease-out-expo in-data-checked:[stroke-dashoffset:0] motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/split-button"
}
