{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/split-button/split-button.tsx","type":"registry:component","target":"@components/arc/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, key: string, reduced: boolean) {\n const width = useMotionValue(\"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 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 \n \n {glyphs.map(glyph => {glyph.char})}\n \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(null);\n const glyph = iconKey(icon);\n const width = useMorphWidth(contentRef, `${glyph}|${label}`, reduced);\n return \n
\n \n \n
\n \n {actions.map((action, index) => {action.icon ? {action.icon} : null}{action.label})}\n \n
;\n}\n\nexport default SplitButton;\n"},{"path":"registry/components/split-button/split-button.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/split-button","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/split-button","markdown":"https://uiarc.dev/components/split-button/markdown","tags":["action","menu"]}}