{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-button",
  "type": "registry:ui",
  "title": "Split button",
  "description": "A primary action with a menu of nearby alternatives.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/split-button/split-button.tsx",
      "type": "registry:component",
      "target": "@components/xzero/split-button/split-button.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode, RefObject } from \"react\";\nimport * as DropdownPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./split-button.module.css\";\n\nexport interface SplitButtonAction { label: string; onSelect?: () => void; disabled?: boolean; destructive?: boolean; icon?: ReactNode; }\nexport interface SplitButtonProps { label: string; actions: SplitButtonAction[]; onClick?: () => void; disabled?: boolean; icon?: ReactNode; variant?: \"primary\" | \"secondary\"; }\n\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\nconst glyphIn: TargetAndTransition = { opacity: 0, y: 5, filter: `blur(${motionTokens.blur.soft}px)` };\nconst glyphOut: TargetAndTransition = { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;\n\n/** Names the icon element, so swapping Copy for Check morphs while a re-render of the same icon stays still. */\nfunction iconKey(node: ReactNode): string {\n  if (!isValidElement(node)) return node == null || typeof node === \"boolean\" ? \"\" : String(node);\n  const type = node.type as string | { displayName?: string; name?: string };\n  return typeof type === \"string\" ? type : type?.displayName ?? type?.name ?? \"icon\";\n}\n\n/** Springs the slot to the natural width of its content when the label changes; other resizes (a late web font) jump. */\nfunction useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key), armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current, slot = node?.parentElement;\n    if (!node || !slot || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.contentRect.width;\n      if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || \"auto\"); delete slot.dataset.morphing; return; }\n      slot.dataset.morphing = \"\";\n      animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [content, reduced, width]);\n  return width;\n}\n\ntype Glyph = { id: string; char: string; order: number };\nconst toGlyphs = (chars: string[], seq: number): Glyph[] => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order }));\n\n/** Shared leading and trailing characters keep their identity, so \"Copy page\" to \"Copied\" only replaces the changed letters. */\nfunction useGlyphs(text: string) {\n  const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));\n  if (state.text === text) return state.glyphs;\n  const prev = [...state.text], next = [...text];\n  let start = 0, end = 0;\n  while (start < prev.length && start < next.length && prev[start] === next[start]) start++;\n  while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;\n  if (start < 2) start = 0;\n  if (end < 2) end = 0;\n  const seq = state.seq + 1;\n  const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq), ...state.glyphs.slice(state.glyphs.length - end)];\n  setState({ text, seq, glyphs });\n  return glyphs;\n}\n\nfunction MorphText({ text, reduced }: { text: string; reduced: boolean }) {\n  const glyphs = useGlyphs(text);\n  return <span className={styles.glyphs}>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {glyphs.map(glyph => <motion.span key={glyph.id} className={styles.glyph} layout={reduced ? false : \"position\"} layoutDependency={text} initial={reduced ? fadeIn : glyphIn} animate={rest} exit={reduced ? fadeOut : glyphOut} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: Math.min(glyph.order * motionTokens.stagger.char, .1), layout: motionTokens.spring.morph }}>{glyph.char}</motion.span>)}\n    </AnimatePresence>\n  </span>;\n}\n\n/** The main action morphs its icon and label in place while its width follows on a spring; the menu half never scales, so the menu opens from a still anchor. */\nexport function SplitButton({ label, actions, onClick, disabled, icon, variant = \"primary\" }: SplitButtonProps) {\n  const reduced = useReducedMotion() ?? false;\n  const contentRef = useRef<HTMLSpanElement>(null);\n  const glyph = iconKey(icon);\n  const width = useMorphWidth(contentRef, `${glyph}|${label}`, reduced);\n  return <DropdownPrimitive.Root>\n    <div className={[styles.group, variant === \"secondary\" ? styles.secondary : \"\"].filter(Boolean).join(\" \")}>\n      <button className={styles.primary} type=\"button\" onClick={onClick} disabled={disabled}>\n        <motion.span className={styles.primarySlot} style={{ width }} aria-hidden=\"true\">\n          <span ref={contentRef} className={styles.primaryContent}>\n            {icon ? <span className={styles.mainIcon}><AnimatePresence initial={false}><motion.span key={glyph} className={styles.iconPhase} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.instant } : iconEnter}>{icon}</motion.span></AnimatePresence></span> : null}\n            <MorphText text={label} reduced={reduced} />\n          </span>\n        </motion.span>\n        <span className={styles.srOnly} aria-live=\"polite\">{label}</span>\n      </button>\n      <DropdownPrimitive.Trigger className={styles.trigger} type=\"button\" aria-label={`${label} more actions`} disabled={disabled}><ChevronDown className={styles.chevron} size={16} strokeWidth={1.75} aria-hidden=\"true\" /></DropdownPrimitive.Trigger>\n    </div>\n    <DropdownPrimitive.Portal><DropdownPrimitive.Content className={styles.menu} sideOffset={4} align=\"end\" collisionPadding={12} loop>\n      {actions.map((action, index) => <DropdownPrimitive.Item key={action.label} className={[styles.item, action.destructive ? styles.destructive : \"\"].filter(Boolean).join(\" \")} style={{ \"--i\": index } as CSSProperties} disabled={action.disabled} onSelect={action.onSelect}>{action.icon ? <span className={styles.icon} aria-hidden=\"true\">{action.icon}</span> : null}{action.label}</DropdownPrimitive.Item>)}\n    </DropdownPrimitive.Content></DropdownPrimitive.Portal>\n  </DropdownPrimitive.Root>;\n}\n\nexport default SplitButton;\n"
    },
    {
      "path": "registry/components/split-button/split-button.module.css",
      "type": "registry:component",
      "target": "@components/xzero/split-button/split-button.module.css",
      "content": "/* One pill, two actions. The whole pill presses in for the main action; the menu half answers in color only, because Radix measures it on pointerdown to place the menu. */\n.group { display: inline-flex; align-items: stretch; border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); transition: transform var(--duration-spring) var(--ease-spring); }\n.group:has(> .primary:active:not(:disabled)) { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n.primary, .trigger { border: 0; background: var(--foreground); color: var(--background); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n.primary { position: relative; display: inline-grid; min-height: var(--control-height-md); place-items: center; padding: 0 var(--space-4); border-radius: calc(var(--radius-control) - 1px) 0 0 calc(var(--radius-control) - 1px); font-size: var(--text-sm); font-weight: 500; }\n/* The slot springs to the width of the next label; the content leads from the start edge and is clipped only while it morphs. */\n.primarySlot { display: inline-flex; min-width: 0; align-items: center; }\n.primarySlot[data-morphing] { clip-path: inset(-50% calc(var(--space-2) * -1)); }\n.primaryContent { display: inline-flex; flex: none; align-items: center; gap: var(--space-2); white-space: nowrap; }\n.mainIcon { display: inline-grid; flex: none; place-items: center; }\n.iconPhase { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; }\n.glyphs { position: relative; display: inline-flex; flex: none; white-space: pre; }\n.glyph { display: inline-block; }\n.trigger { display: grid; width: 38px; min-height: var(--control-height-md); place-items: center; border-left: 1px solid color-mix(in oklab, var(--background) 18%, var(--foreground)); border-radius: 0 calc(var(--radius-control) - 1px) calc(var(--radius-control) - 1px) 0; }\n.chevron { transition: transform var(--duration-spring) var(--ease-spring); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n@media (hover: hover) and (pointer: fine) {\n  .primary:hover:not(:disabled), .trigger:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 88%, var(--background)); }\n  .secondary .primary:hover:not(:disabled), .secondary .trigger:hover:not(:disabled) { background: var(--surface-muted); }\n}\n.primary:active:not(:disabled), .trigger:active:not(:disabled), .trigger[data-state=\"open\"] { background: color-mix(in oklab, var(--foreground) 80%, var(--background)); }\n.primary:disabled, .trigger:disabled { cursor: not-allowed; opacity: .52; }\n.secondary { border-color: var(--border); border-radius: var(--radius-pill); background: var(--surface); }\n.secondary .primary, .secondary .trigger { min-height: var(--control-height-sm); background: var(--surface); color: var(--foreground); }\n.secondary .primary { min-width: 142px; border-radius: var(--radius-pill) 0 0 var(--radius-pill); padding-inline: var(--space-3); font-size: var(--text-xs); }\n.secondary .trigger { width: 32px; border-left-color: var(--border-subtle); border-radius: 0 var(--radius-pill) var(--radius-pill) 0; }\n.secondary .primary:active:not(:disabled), .secondary .trigger:active:not(:disabled), .secondary .trigger[data-state=\"open\"] { background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); }\n.icon { display: inline-flex; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly; it leaves faster than it arrives.\n   Transitions instead of keyframes, so a close that interrupts the open reverses from where the menu is instead of snapping to rest first. */\n.menu { --menu-x: 0px; --menu-y: -5px; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }\n.menu[data-side=\"top\"] { --menu-y: 5px; }\n.menu[data-side=\"left\"] { --menu-x: 5px; --menu-y: 0px; }\n.menu[data-side=\"right\"] { --menu-x: -5px; --menu-y: 0px; }\n@starting-style {\n  .menu[data-state=\"open\"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }\n  .menu[data-state=\"open\"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }\n}\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.menu[data-state=\"closed\"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity 130ms var(--ease-standard), transform 130ms var(--ease-standard); animation: menu-exit 130ms linear both; pointer-events: none; }\n/* Highlight follows the pointer and arrow keys instantly, like a native menu. */\n.item { display: flex; min-height: var(--control-height-sm); align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); font-size: var(--text-sm); cursor: pointer; outline: none; transition: opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }\n.item[data-highlighted] { background: var(--surface-muted); }\n.item[data-disabled] { cursor: default; opacity: .45; }\n.destructive { color: var(--danger); }\n.destructive[data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }\n@keyframes menu-exit { to { --menu-exit: 1; } }\n@media (prefers-reduced-motion: reduce) {\n  .group, .primary, .trigger, .chevron { transition: none; }\n  .group:has(> .primary:active:not(:disabled)) { transform: none; }\n  .item { transition: none; }\n  .menu, .menu[data-state=\"closed\"] { transform: none; transition: opacity var(--duration-instant) linear; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/split-button",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "action",
      "menu"
    ],
    "docs": "https://ui.xzero.co.uk/components/split-button",
    "markdown": "https://ui.xzero.co.uk/components/split-button/markdown"
  }
}