Split Button
A primary action joined to a trailing chevron that opens a menu of secondary actions.
Overview
A split button pairs a primary action with a small trailing chevron that opens a menu of related, secondary actions. Splitting the two keeps the main action one click away while tucking the extra options behind the menu — reducing visual complexity without hiding them entirely. Reach for it when one action dominates but a few close alternatives deserve a home (Save / Save as… / Save a copy).
It is a composable shell built from three parts: SplitButton sets the shared style
and groups the two halves with a 2px gap, SplitButtonAction is the leading primary
button, and SplitButtonTrigger is the trailing chevron. It shares the Button's
geometry, state-layer model and focus ring, and the same five styles — filled
(default), tonal, outlined, elevated and glass. Outer corners follow
the shape; the two inner facing corners stay small (and grow with size) for a
harmonious nested look.
The trigger is menu-agnostic: it defaults aria-haspopup="menu" and supports the
render prop, so you can hand it to a Base UI Menu/Popover trigger, or drive it
yourself with aria-expanded + onClick. Either way it holds its state layer and
rotates the chevron 180° while the menu is open.
$ npx morpha-ui add split-buttonExamples
Variants
The five styles match the rest of the button family. Both halves always share one
style. glass is translucent and is meant to sit over imagery or a colored backdrop.
Sizes
Five sizes scale padding, label and icon together — xs (32px), sm (40px,
default), md (56px), lg (96px) and xl (136px). xs/sm keep a 44×44px hit
target on each half for accessibility.
Radius
shape controls the outer corners — full (default), rounded (12px),
squared (a per-size radius that grows with the button) and none. The two inner
facing corners stay small so the halves read as one split control.
Usage
The trailing trigger is a shell — bring your own menu. The cleanest path is to hand
it to a menu trigger via render; the trigger reflects data-popup-open, which
holds the state layer and rotates the chevron (no extra wiring):
'use client';
import { Save } from 'lucide-react';
import { Menu } from '@base-ui/react/menu';
import {
SplitButton,
SplitButtonAction,
SplitButtonTrigger,
} from '@/components/ui/split-button';
export function Example() {
return (
<SplitButton aria-label="Save options">
<SplitButtonAction onClick={onSave}>
<Save />
Save
</SplitButtonAction>
<Menu.Root>
<Menu.Trigger
render={<SplitButtonTrigger aria-label="More save options" />}
/>
<Menu.Portal>
<Menu.Positioner align="end" sideOffset={8}>
<Menu.Popup>{/* …items… */}</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</SplitButton>
);
}Prefer to control it yourself? Wire aria-expanded + onClick on the trigger
instead and render your own menu:
<SplitButtonTrigger
aria-label="More save options"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
/>Installation
Morpha UI components are distributed via the Morpha CLI (coming in a later
step): npx morpha-ui add split-button.
API Reference
Accessibility
- Renders two native
<button>s grouped in arole="group"— give the group anaria-labeldescribing the action set. - Provide an
aria-labelonSplitButtonTrigger— it is icon-only, so there is no visible text to name it. It defaultsaria-haspopup="menu"; wirearia-expanded(and ideallyaria-controls) to your menu. - A visible focus ring (3px accent with a 2px offset) is provided on each half via
focus-visible. prefers-reduced-motionkeeps the state-layer/color transitions but drops the press scale and the chevron's animated rotation (the open state is still indicated).loadingonSplitButtonActionsetsaria-busyand disables interaction while work is in flight;disabledonSplitButtondisables both halves.