Split Button
A prominent action with a menu of related actions attached to it
A split button is two controls that read as one: a dominant action and a menu of variations on that action. Reach for it when one option is right most of the time and the alternatives are worth keeping one tap away — merging a pull request, sending now versus scheduling, saving versus saving a copy.
Use something else when the options are equal in weight (a plain Dropdown Menu, so nothing looks preferred), when there is no sensible default action, or when the extra actions are unrelated to the main one (a primary Button beside an overflow Icon Button).
Playground
Installation
pnpm add @tessinaui/uiUsage
import { SplitButton } from "@tessinaui/ui";<SplitButton label="Create project">
<SplitButton.Item>Blank project</SplitButton.Item>
<SplitButton.Item>Use a template</SplitButton.Item>
</SplitButton>The children are menu contents. SplitButton.Item and its siblings are the
Dropdown Menu parts, so anything that works in
a menu works here.
Rules
- Never repeat the primary action inside the menu. The button already does it; a duplicate row makes people wonder which one is real.
- Keep the menu short — under about six items. A long list belongs in a plain menu, where nothing pretends to be the default.
- One clear default. If you cannot name the action people want most of the time, this is the wrong component.
- Destructive options go in the menu, last, with
intent="error"— never as the primary action. - Label both halves in full. The menu button's name is composed for you
("More options, Save"); override it with
menuLabelto translate or to phrase it per the action ("More merge options").
Examples
Default
The primary action, a seam, and the menu button. The seam fades while either half is hovered or the menu is held open.
Variants
Four visual weights, shared by both halves so the pair reads as one control.
Every variant — outline included — marks the join with the same short seam;
the outline's outer border stays continuous around the pair.
Intents
Semantic meaning. A destructive primary action is variant="primary" intent="error"; a destructive option is a menu item with intent="error".
Sizes
Five sizes. xs and sm grow to the 44px touch floor on phones, on both axes,
so a short label cannot fall under the target size.
Rounded
Every step of the radius scale. Outer corners follow rounded; the corners at
the join stay square, so the control reads as one object with a seam rather than
two pills pushed together. The menu's radius follows the same prop.
With icon
A leading icon before the label. Icons are decorative — the label names the action.
Menu features
Groups with labels, per-item descriptions, keyboard shortcuts, leading icons, separators and link rows — the menu's full vocabulary.
Destructive option
intent="error" on the item, last in the menu, separated from the safe
choices.
Remembered default
A radio group in the menu, with the chosen option becoming the primary action's label — the action you picked last is the one the button does next.
Pending action
loading keeps the action focused and reports aria-busy instead of natively
disabling it, so focus is never thrown away mid-interaction. The label stays
readable and the spinner takes the leading-icon slot — swapping an existing
leadingIcon, so the button's width does not move. The menu stays operable
while the action is in flight, which is what lets a long-running or
agent-driven action offer a cancel.
Two notes on why it works this way, since most systems do something else. The
label is the only thing distinguishing this control's two segments, so hiding it
behind a spinner leaves two targets with nothing to read — of eleven systems
surveyed, the seven that thought hardest about button loading keep the label and
give the spinner a slot (Apple, Primer, Ant Design, Polaris, Chakra, MUI at
start/end, Carbon), and the ones that centre-and-hide were designing for a
solo button with no slot to borrow. And a spinner centred on the whole control
would sit on the seam, claiming a menu half that is still live.
Pair it with a label that says what is happening — "Deploy" → "Deploying…" — the way Apple's HIG describes. That reads only because the label survives.
States
Disabled and loading. disabled takes both halves; loading takes only the
action.
Controlled
open with onOpenChange, plus defaultOpen for the uncontrolled case.
Placement
align defaults to end, so the menu hangs under the chevron. side and
sideOffset cover the rest.
On a coloured surface
Inside a Surface, both halves inherit tone="on-color" with no prop. The
filled pill publishes its own focus-ring offset, so the ring does not cut a
page-coloured notch out of the middle of the control.
API Reference
SplitButton
className and style land on the wrapper — the element that owns the
control's box. Every other DOM attribute (id, data-*, aria-describedby,
form, name, onKeyDown, …) is forwarded to the action button, which is
also what ref points at.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The primary action's label. Also names the menu button |
variant | "primary" | "secondary" | "ghost" | "outline" | "primary" | Visual weight, shared by both halves |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic meaning |
tone | "default" | "on-color" | inherited | Whether the control sits on a coloured surface. Inherited from <Surface>; an explicit value wins |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner treatment |
leadingIcon | React.ReactNode | — | Icon before the label. Decorative |
trailingIcon | React.ReactNode | — | Icon after the label. Decorative |
onClick | MouseEventHandler | — | The primary action |
disabled | boolean | false | Unavailable. Disables both halves |
loading | boolean | false | The action is busy: keeps focus and its label, reports aria-busy, puts a spinner in the leading-icon slot, swallows activation. The menu stays operable |
open | boolean | — | Menu open state (controlled) |
defaultOpen | boolean | false | Initial menu open state (uncontrolled) |
onOpenChange | (open: boolean) => void | — | Called when the menu opens or closes |
menuLabel | string | "More options" | Accessible name for the menu button, announced in front of the action's label |
side | "top" | "bottom" | "left" | "right" | "bottom" | Which side the menu opens on |
align | "start" | "center" | "end" | "end" | Menu alignment against the control |
sideOffset | number | 4 | Gap in px between control and menu |
menuClassName | string | — | Classes for the menu popup. It renders in a portal, so pass a theme class here to style it |
menuStyle | CSSProperties | — | Inline styles for the menu popup |
menuPositionerClassName | string | — | Classes for the menu's positioner — raise the menu's z-index here, not on the popup |
className | string | — | Classes for the wrapper |
style | CSSProperties | — | Inline styles for the wrapper |
children | React.ReactNode | — | Menu contents |
Menu parts
These are re-exports of the Dropdown Menu components — the same components, not lookalikes — so their props are documented there in full.
| Part | Notes |
|---|---|
SplitButton.Item | A menu row. Takes leadingIcon, trailingIcon, description, shortcut, badge, intent="error", disabled |
SplitButton.LinkItem | A row that navigates; renders an <a> |
SplitButton.Separator | A divider between rows |
SplitButton.Group + SplitButton.Label | A titled group of rows |
SplitButton.CheckboxItem / SplitButton.SwitchItem | Toggleable rows |
SplitButton.RadioGroup + SplitButton.RadioItem | One-of-many rows — the remembered-default pattern |
SplitButton.Shortcut | Keyboard hint inside a custom row |
SplitButton.Sub + .SubTrigger + .SubContent | A submenu |
SplitButtonSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ButtonVariant | "primary" | Mirror the real control's variant so the frame keeps its geometry |
size | SplitButtonSize | "md" | — |
rounded | ButtonRounded | "full" | — |
actionWidth | string | "w-[8ch]" | Width class of the label placeholder |
chars | number | — | Label length in characters; overrides actionWidth |
Sizes
| Size | Height | Use case |
|---|---|---|
xs | 32px | Compact toolbars |
sm | 40px | Secondary actions |
md | 48px | Default |
lg | 56px | Prominent actions |
xl | 96px | Hero CTAs |
Heights are the desktop scale. Below the md breakpoint (768px) xs and sm
grow to a 44×44 touch target on both axes per WCAG 2.5.5 — height and width,
for the action half and the menu button alike. md and up already clear it.
Data attributes
Stable hooks for styling and for automation.
| Attribute | On | When |
|---|---|---|
data-slot="split-button" | wrapper | always |
data-slot="split-button-action" | action button | always |
data-slot="split-button-divider" | seam (inside the menu half, on the join) | always |
data-slot="split-button-trigger" | menu button | always |
data-open | wrapper | while the menu is open |
data-disabled | wrapper | while disabled |
data-loading | wrapper and action | while loading and not disabled |
data-slot="split-button-spinner" | busy spinner, inside the action | while loading and not disabled |
Accessibility
Two buttons, two tab stops. The action half is a plain button; the menu
button is the only one carrying aria-haspopup="menu" and aria-expanded. The
wrapper is presentational — it takes no role, so assistive technology announces
two controls rather than a group wrapping them.
Names. The menu button's accessible name is composed from menuLabel and
the action's label, so it carries enough context without a second visible
label. Measured with VoiceOver on Safari, for a button labelled "Create
project":
- action half → "Create project, button"
- menu button → "More options Create project, menu pop up button"
- opening it → "Blank project, menu item, 1 of 4, menu, More options Create project" — the menu is labelled by its trigger, and focus lands on the first item
menuLabel is the translation and phrasing hook for the leading part.
Keyboard.
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Move between the action and the menu button, then out of the control |
Enter / Space | On the action, perform it. On the menu button, open the menu |
↓ / ↑ | On the menu button, open the menu at the first / last item |
↓ / ↑ | In the menu, move between items |
Home / End | In the menu, jump to the first / last item |
Enter | In the menu, activate the item and close |
Esc | Close the menu and return focus to the menu button |
Busy state. loading sets aria-busy and aria-disabled rather than the
native disabled attribute, so the control keeps focus — a button that natively
disables itself the moment it is clicked throws focus to the page. The label
stays visible and in the accessible name while the spinner shows, so the control
never loses its name mid-action. The menu button is untouched by loading: only
disabled takes both halves.
Contrast. Both halves use the button family's colour rows, which are audited
for WCAG AA in both themes on every supported surface. The seam is deliberately
soft — measured 1.95–2.64:1 against its own segment fill across every variant
and both themes — and treated as decorative reinforcement: each half is
identifiable by its own label or chevron, which is the position Fluent and
Atlassian ship. The chevron itself sits at 60% ink and still clears the 3:1
non-text threshold on every fill. The wrapper also publishes
--ring-offset-surface, so the focus ring's offset gap is painted in the
control's own fill rather than the page's.
Screen readers. Verified with VoiceOver on Safari (macOS) — the announced strings above are captured from a real run, not inferred. NVDA and JAWS are untested: no Windows machine is available to this project, and an untested combination is not claimed as supported.