UIInteractive Buttons and Controlsmotion/react

SplitButton

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 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add split-button

Writes 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.

example.tsx
1import { SplitButton } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SplitButton />6  );7}

Props

PropTypeDefault
label

Label of the primary action.

string'Deploy to production'
actions

Secondary actions listed in the menu (must be unique).

string[]['Deploy to preview', 'Deploy without cache', 'Schedule deploy…', 'Roll back to previous']
onClick

Called when the primary part is pressed, with its current label.

(label: string) => voidundefined
onAction

Called when a menu action is chosen.

(action: string, index: number) => voidundefined
rememberLast

Swap the chosen action into the primary slot (the old primary takes its place in the menu).

booleanfalse
open

Controlled menu open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called when the menu requests to open or close.

(open: boolean) => voidundefined
menuLabel

Accessible label of the chevron trigger and the menu.

string'More deploy options'
variant

Visual style.

'default' | 'secondary' | 'outline''default'
size

Size preset.

'sm' | 'md' | 'lg''md'
side

Preferred menu side; flips when it does not fit in the viewport.

'bottom' | 'top''bottom'
align

Align the menu with the left or right edge of the button.

'start' | 'end''end'
accentColor

Brand color for the default variant.

string'#6366f1'
disabled

Disable both parts.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/split-button.tsx
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}