A primary action fused to a chevron that opens an animated menu of secondary actions. The chevron flips, the menu scales in from its anchored corner with staggered items, flips above the button when there is no room below, and can promote the chosen action into the primary slot.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add split-buttonWrites components/ui/split-button.tsx and components/styles/split-button.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SplitButton } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SplitButton />6 );7}Props
Accessibility
Follows the WAI-ARIA menu button pattern: the chevron has aria-label, aria-haspopup="menu", aria-expanded and aria-controls; the panel is role="menu" with role="menuitem" buttons. Enter/Space or ArrowDown opens and focuses the first item, ArrowUp the last; inside the menu ArrowUp/Down wrap, Home/End jump, printable characters type-ahead, Enter/Space activate, Escape closes and returns focus to the chevron, Tab closes, and outside clicks close. Under prefers-reduced-motion the menu only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/split-button.module.css";6 7export interface SplitButtonProps {8 /** Label of the primary action */9 label?: string;10 /** Secondary actions listed in the menu */11 actions?: string[];12 /** Called when the primary part is pressed, with its current label */13 onClick?: (label: string) => void;14 /** Called when a menu action is chosen */15 onAction?: (action: string, index: number) => void;16 /** Swap a chosen menu action into the primary slot */17 rememberLast?: boolean;18 /** Controlled menu open state */19 open?: boolean;20 /** Initial menu open state when uncontrolled */21 defaultOpen?: boolean;22 /** Called when the menu requests to open or close */23 onOpenChange?: (open: boolean) => void;24 /** Accessible label of the chevron trigger */25 menuLabel?: string;26 /** Visual style */27 variant?: "default" | "secondary" | "outline";28 /** Size preset */29 size?: "sm" | "md" | "lg";30 /** Preferred side of the menu (flips when it does not fit) */31 side?: "bottom" | "top";32 /** Menu alignment against the button */33 align?: "start" | "end";34 /** Brand color for the default variant */35 accentColor?: string;36 /** Disable both parts */37 disabled?: boolean;38 /** Optional className merged onto the root */39 className?: string;40 /** Optional inline styles merged onto the root */41 style?: React.CSSProperties;42}43 44const DEFAULT_ACTIONS = ["Deploy to preview", "Deploy without cache", "Schedule deploy…", "Roll back to previous"];45 46export function SplitButton({47 label = "Deploy to production",48 actions = DEFAULT_ACTIONS,49 onClick,50 onAction,51 rememberLast = false,52 open: openProp,53 defaultOpen = false,54 onOpenChange,55 menuLabel = "More deploy options",56 variant = "default",57 size = "md",58 side = "bottom",59 align = "end",60 accentColor = "#6366f1",61 disabled = false,62 className = "",63 style,64}: SplitButtonProps) {65 const reduce = useReducedMotion();66 const [innerOpen, setInnerOpen] = React.useState(defaultOpen);67 const open = openProp ?? innerOpen;68 const [list, setList] = React.useState<string[]>(() => [label, ...actions]);69 const [placement, setPlacement] = React.useState(side);70 const rootRef = React.useRef<HTMLDivElement>(null);71 const triggerRef = React.useRef<HTMLButtonElement>(null);72 const menuRef = React.useRef<HTMLDivElement>(null);73 const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);74 const focusOnOpen = React.useRef<"first" | "last">("first");75 const menuId = React.useId();76 77 const actionsKey = actions.join("\n");78 React.useEffect(() => {79 setList([label, ...actionsKey.split("\n").filter(Boolean)]);80 }, [label, actionsKey]);81 82 const primary = list[0];83 const menuItems = list.slice(1);84 85 const setOpen = React.useCallback(86 (v: boolean, restoreFocus = false) => {87 if (openProp === undefined) setInnerOpen(v);88 onOpenChange?.(v);89 if (!v && restoreFocus) triggerRef.current?.focus();90 },91 [openProp, onOpenChange],92 );93 94 // Placement flip, initial focus and outside click while open.95 React.useEffect(() => {96 if (!open) return;97 const trigger = rootRef.current?.getBoundingClientRect();98 const menuH = menuRef.current?.offsetHeight ?? 0;99 if (trigger) {100 const below = window.innerHeight - trigger.bottom;101 const above = trigger.top;102 if (side === "bottom" && below < menuH + 12 && above > below) setPlacement("top");103 else if (side === "top" && above < menuH + 12 && below > above) setPlacement("bottom");104 else setPlacement(side);105 }106 const raf = requestAnimationFrame(() => {107 const nodes = itemRefs.current.filter((n): n is HTMLButtonElement => !!n);108 (focusOnOpen.current === "last" ? nodes[nodes.length - 1] : nodes[0])?.focus();109 });110 const onDown = (e: PointerEvent) => {111 if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);112 };113 document.addEventListener("pointerdown", onDown);114 return () => {115 cancelAnimationFrame(raf);116 document.removeEventListener("pointerdown", onDown);117 };118 }, [open, side, setOpen]);119 120 const choose = (index: number) => {121 const action = menuItems[index];122 onAction?.(action, index);123 if (rememberLast) setList((l) => [action, ...l.slice(1).map((a) => (a === action ? l[0] : a))]);124 setOpen(false, true);125 };126 127 const onTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {128 if (e.key === "ArrowDown" || e.key === "ArrowUp") {129 e.preventDefault();130 focusOnOpen.current = e.key === "ArrowUp" ? "last" : "first";131 setOpen(true);132 }133 };134 135 const onMenuKey = (e: React.KeyboardEvent<HTMLDivElement>) => {136 const nodes = itemRefs.current.filter((n): n is HTMLButtonElement => !!n);137 const i = nodes.indexOf(document.activeElement as HTMLButtonElement);138 let next = -1;139 if (e.key === "ArrowDown") next = (i + 1) % nodes.length;140 else if (e.key === "ArrowUp") next = (i - 1 + nodes.length) % nodes.length;141 else if (e.key === "Home") next = 0;142 else if (e.key === "End") next = nodes.length - 1;143 else if (e.key === "Escape") {144 e.preventDefault();145 setOpen(false, true);146 return;147 } else if (e.key === "Tab") {148 setOpen(false);149 return;150 } else if (e.key.length === 1 && /\S/.test(e.key)) {151 const ch = e.key.toLowerCase();152 for (let k = 1; k <= nodes.length; k++) {153 const j = (i + k) % nodes.length;154 if (menuItems[j]?.toLowerCase().startsWith(ch)) {155 next = j;156 break;157 }158 }159 }160 if (next < 0) return;161 e.preventDefault();162 nodes[next]?.focus();163 };164 165 const origin = `${placement === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;166 167 return (168 <div169 ref={rootRef}170 className={[styles.root, styles[variant], styles[size], className].filter(Boolean).join(" ")}171 style={{ "--mf-sb-accent": accentColor, ...style } as React.CSSProperties}172 >173 <button type="button" className={styles.main} disabled={disabled} onClick={() => onClick?.(primary)}>174 {primary}175 </button>176 <span className={styles.divider} aria-hidden="true" />177 <button178 ref={triggerRef}179 type="button"180 className={styles.trigger}181 disabled={disabled}182 aria-label={menuLabel}183 aria-haspopup="menu"184 aria-expanded={open}185 aria-controls={open ? menuId : undefined}186 onKeyDown={onTriggerKey}187 onClick={() => {188 focusOnOpen.current = "first";189 setOpen(!open);190 }}191 >192 <svg aria-hidden="true" className={styles.chevron} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">193 <path d="m6 9 6 6 6-6" />194 </svg>195 </button>196 <AnimatePresence>197 {open && !disabled ? (198 <motion.div199 ref={menuRef}200 id={menuId}201 role="menu"202 aria-label={menuLabel}203 tabIndex={-1}204 onKeyDown={onMenuKey}205 className={styles.menu}206 data-side={placement}207 data-align={align}208 style={{ transformOrigin: origin }}209 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: placement === "bottom" ? -4 : 4 }}210 animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}211 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: placement === "bottom" ? -2 : 2 }}212 transition={{ duration: reduce ? 0.1 : 0.16, ease: [0.16, 1, 0.3, 1] }}213 >214 {menuItems.map((action, i) => (215 <motion.button216 key={action}217 ref={(el: HTMLButtonElement | null) => {218 itemRefs.current[i] = el;219 }}220 type="button"221 role="menuitem"222 tabIndex={-1}223 className={styles.item}224 onClick={() => choose(i)}225 onPointerMove={(e) => e.currentTarget.focus()}226 initial={reduce ? false : { opacity: 0, x: align === "end" ? 6 : -6 }}227 animate={{ opacity: 1, x: 0 }}228 transition={{ duration: 0.18, delay: reduce ? 0 : 0.03 + i * 0.025 }}229 >230 {action}231 </motion.button>232 ))}233 </motion.div>234 ) : null}235 </AnimatePresence>236 </div>237 );238}