{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\n\nexport interface SplitButtonAction {\n  label: string;\n  onSelect?: () => void;\n  disabled?: boolean;\n  destructive?: boolean;\n  icon?: ReactNode;\n}\nexport interface SplitButtonProps {\n  label: string;\n  actions: SplitButtonAction[];\n  onClick?: () => void;\n  disabled?: boolean;\n  icon?: ReactNode;\n  variant?: \"primary\" | \"secondary\";\n}\n\n/* One pill, two actions. The whole pill presses in for the main action; the menu half answers in colour only, because Radix\n   measures it on pointerdown to place the menu. */\nconst groupClass = {\n  primary:\n    \"inline-flex items-stretch rounded-button border border-ink bg-ink [transition:transform_var(--duration-spring)_var(--ease-spring)] has-[>:first-child:active:not(:disabled)]:[transform:scale(.97)] has-[>:first-child:active:not(:disabled)]:duration-instant has-[>:first-child:active:not(:disabled)]:ease-standard motion-reduce:transition-none motion-reduce:has-[>:first-child:active:not(:disabled)]:[transform:none]\",\n  secondary:\n    \"inline-flex items-stretch rounded-button border border-line bg-surface [transition:transform_var(--duration-spring)_var(--ease-spring)] has-[>:first-child:active:not(:disabled)]:[transform:scale(.97)] has-[>:first-child:active:not(:disabled)]:duration-instant has-[>:first-child:active:not(:disabled)]:ease-standard motion-reduce:transition-none motion-reduce:has-[>:first-child:active:not(:disabled)]:[transform:none]\",\n};\n/* Both halves share the fill and press colours; an open menu keeps its half pressed. */\nconst halfClass = {\n  primary:\n    \"cursor-pointer bg-ink text-page [-webkit-tap-highlight-color:transparent] transition-[background-color,color,opacity] duration-fast ease-standard enabled:hover:bg-[color-mix(in_oklab,var(--ink)_88%,var(--page))] enabled:active:bg-[color-mix(in_oklab,var(--ink)_80%,var(--page))] disabled:cursor-not-allowed disabled:opacity-52 data-[state=open]:bg-[color-mix(in_oklab,var(--ink)_80%,var(--page))] motion-reduce:transition-none\",\n  secondary:\n    \"cursor-pointer bg-surface text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color,color,opacity] duration-fast ease-standard enabled:hover:bg-hover enabled:active:bg-[color-mix(in_oklab,var(--hover),var(--line)_55%)] disabled:cursor-not-allowed disabled:opacity-52 data-[state=open]:bg-[color-mix(in_oklab,var(--hover),var(--line)_55%)] motion-reduce:transition-none\",\n};\nconst primaryClass = {\n  primary:\n    \"relative inline-grid min-h-control-md place-items-center rounded-l-button px-4 text-sm leading-[inherit] font-medium\",\n  secondary:\n    \"relative inline-grid min-h-control-sm min-w-[142px] place-items-center rounded-l-button px-3 text-xs leading-[inherit] font-medium\",\n};\nconst triggerClass = {\n  primary:\n    \"group/trig grid min-h-control-md w-[38px] place-items-center rounded-r-button border-l border-l-[color-mix(in_oklab,var(--page)_18%,var(--ink))]\",\n  secondary: \"group/trig grid min-h-control-sm w-8 place-items-center rounded-r-button border-l border-l-line-soft\",\n};\n/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly; it leaves faster\n   than it arrives. Transitions instead of keyframes, so a close that interrupts the open reverses from where the menu is instead\n   of snapping to rest first. Radix unmounts when the exit animation ends, so a no-op keyframe (split-button-menu-exit) times the\n   exit while the transition does the visual work. */\nconst menuClass =\n  \"group/menu z-60 max-w-(--radix-dropdown-menu-content-available-width) min-w-[min(12rem,var(--radix-dropdown-menu-content-available-width))] origin-(--radix-dropdown-menu-content-transform-origin) rounded-panel border border-line bg-surface p-[5px] text-ink shadow-floating [--menu-x:0px] [--menu-y:-5px] [transition:opacity_var(--duration-fast)_var(--ease-enter),transform_var(--duration-spring)_var(--ease-spring)] data-[side=left]:[--menu-x:5px] data-[side=left]:[--menu-y:0px] data-[side=right]:[--menu-x:-5px] data-[side=right]:[--menu-y:0px] data-[side=top]:[--menu-y:5px] data-[state=open]:starting:opacity-0 data-[state=open]:starting:[transform:translate(var(--menu-x),var(--menu-y))_scale(.97)] data-[state=closed]:pointer-events-none data-[state=closed]:animate-split-button-menu-exit data-[state=closed]:opacity-0 data-[state=closed]:[transform:translate(calc(var(--menu-x)*.5),calc(var(--menu-y)*.5))_scale(.985)] data-[state=closed]:[transition:opacity_130ms_var(--ease-standard),transform_130ms_var(--ease-standard)] motion-reduce:[transform:none] motion-reduce:[transition:opacity_var(--duration-instant)_linear] motion-reduce:data-[state=closed]:[transform:none] motion-reduce:data-[state=closed]:[transition:opacity_var(--duration-instant)_linear]\";\n/* Highlight follows the pointer and arrow keys instantly, like a native menu. */\nconst itemClass =\n  \"flex min-h-control-sm cursor-pointer items-center gap-2.5 rounded-[calc(var(--radius-panel)-6px)] px-[11px] text-sm leading-[inherit] 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)] data-disabled:cursor-default data-disabled:opacity-45 data-highlighted:bg-hover group-data-[state=open]/menu:starting:opacity-0 group-data-[state=open]/menu:starting:[transform:translate(calc(var(--menu-x)*.4),calc(var(--menu-y)*.4))] motion-reduce:transition-none\";\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 = {\n  opacity: 0,\n  y: -4,\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = {\n  ...iconIn,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n  filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n} 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))\n    return typeof node === \"string\" || typeof node === \"number\" || typeof node === \"bigint\" ? 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),\n    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,\n      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) {\n        measured = next > 0;\n        width.jump(next || \"auto\");\n        delete slot.dataset.morphing;\n        return;\n      }\n      slot.dataset.morphing = \"\";\n      animate(width, next, {\n        ...motionTokens.spring.morph,\n        onComplete: () => {\n          delete slot.dataset.morphing;\n        },\n      });\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 };\n/** Splits a label into user-perceived characters, so an accented letter or an emoji morphs as one glyph. */\nconst graphemer = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\nconst graphemes = (text: string) => Array.from(graphemer.segment(text), (part) => part.segment);\nconst toGlyphs = (chars: string[], seq: number): Glyph[] =>\n  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(graphemes(text), 0) }));\n  if (state.text === text) return state.glyphs;\n  const prev = graphemes(state.text),\n    next = graphemes(text);\n  let start = 0,\n    end = 0;\n  while (start < prev.length && start < next.length && prev[start] === next[start]) start++;\n  while (\n    end < prev.length - start &&\n    end < next.length - start &&\n    prev[prev.length - 1 - end] === next[next.length - 1 - end]\n  )\n    end++;\n  if (start < 2) start = 0;\n  if (end < 2) end = 0;\n  const seq = state.seq + 1;\n  const glyphs = [\n    ...state.glyphs.slice(0, start),\n    ...toGlyphs(next.slice(start, next.length - end), seq),\n    ...state.glyphs.slice(state.glyphs.length - end),\n  ];\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 (\n    <span className=\"relative inline-flex flex-none whitespace-pre\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {glyphs.map((glyph) => (\n          <motion.span\n            key={glyph.id}\n            className=\"inline-block\"\n            layout={reduced ? false : \"position\"}\n            layoutDependency={text}\n            initial={reduced ? fadeIn : glyphIn}\n            animate={rest}\n            exit={reduced ? fadeOut : glyphOut}\n            transition={\n              reduced\n                ? { duration: motionTokens.duration.instant }\n                : {\n                    duration: motionTokens.duration.standard,\n                    ease: [...motionTokens.ease.enter],\n                    delay: Math.min(glyph.order * motionTokens.stagger.char, 0.1),\n                    layout: motionTokens.spring.morph,\n                  }\n            }\n          >\n            {glyph.char}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\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 (\n    <DropdownPrimitive.Root>\n      <div className={groupClass[variant]}>\n        <button\n          className={cn(halfClass[variant], primaryClass[variant])}\n          type=\"button\"\n          onClick={onClick}\n          disabled={disabled}\n        >\n          <motion.span\n            className=\"inline-flex min-w-0 items-center data-morphing:[clip-path:inset(-50%_calc(var(--space-2)*-1))]\"\n            style={{ width }}\n            aria-hidden=\"true\"\n          >\n            <span ref={contentRef} className=\"inline-flex flex-none items-center gap-2 whitespace-nowrap\">\n              {icon ? (\n                <span className=\"inline-grid flex-none place-items-center\">\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={glyph}\n                      className=\"inline-flex items-center justify-center [grid-area:1/1]\"\n                      initial={reduced ? fadeIn : iconIn}\n                      animate={rest}\n                      exit={reduced ? fadeOut : iconOut}\n                      transition={reduced ? { duration: motionTokens.duration.instant } : iconEnter}\n                    >\n                      {icon}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n              ) : null}\n              <MorphText text={label} reduced={reduced} />\n            </span>\n          </motion.span>\n          <span className=\"absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0_0_0_0)]\" aria-live=\"polite\">\n            {label}\n          </span>\n        </button>\n        <DropdownPrimitive.Trigger\n          className={cn(halfClass[variant], triggerClass[variant])}\n          type=\"button\"\n          aria-label={`${label} more actions`}\n          disabled={disabled}\n        >\n          <ChevronDown\n            className=\"[transition:transform_var(--duration-spring)_var(--ease-spring)] group-data-[state=open]/trig:[transform:rotate(180deg)] motion-reduce:transition-none\"\n            size={16}\n            strokeWidth={1.75}\n            aria-hidden=\"true\"\n          />\n        </DropdownPrimitive.Trigger>\n      </div>\n      <DropdownPrimitive.Portal>\n        <DropdownPrimitive.Content className={menuClass} sideOffset={4} align=\"end\" collisionPadding={12} loop>\n          {actions.map((action, index) => (\n            <DropdownPrimitive.Item\n              key={action.label}\n              className={cn(\n                itemClass,\n                action.destructive && \"text-red data-highlighted:bg-[color-mix(in_oklab,var(--red)_8%,var(--surface))]\",\n              )}\n              style={{ \"--i\": index } as CSSProperties}\n              disabled={action.disabled}\n              onSelect={action.onSelect}\n            >\n              {action.icon ? (\n                <span className=\"inline-flex\" aria-hidden=\"true\">\n                  {action.icon}\n                </span>\n              ) : null}\n              {action.label}\n            </DropdownPrimitive.Item>\n          ))}\n        </DropdownPrimitive.Content>\n      </DropdownPrimitive.Portal>\n    </DropdownPrimitive.Root>\n  );\n}\n\nexport default SplitButton;\n",
      "type": "registry:component",
      "target": "@components/iris/split-button/split-button.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/split-button",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "SplitButton",
    "tags": [
      "action",
      "menu"
    ],
    "docs": "https://irisui.dev/components/split-button"
  }
}
