Split Button
A prominent action with a menu of related actions attached to it
pnpm add @tessinaui/uiA split button is two controls that read as one: a dominant action and a menu of variations on that action.
When to use
- One option is right most of the time. The alternatives are worth keeping one tap away: merging a pull request, sending now versus scheduling, saving versus saving a copy.
- The extra actions are variations of the main one. They share its object and its outcome, so the menu reads as "the same thing, done differently".
- You can name the default. The action people want most often is the one on the button, never hidden in the menu.
- Space is tight. One control carries the default and a handful of related alternatives, instead of a row of separate buttons.
- Both halves can be labelled in full. The action's label names the default, and the menu button's name is composed from it, so the pair stays clear to a screen-reader user.
When not to use
- The options are equal in weight. Use a plain Dropdown Menu, so nothing looks preferred.
- There is no sensible default action. If you cannot name what people want most of the time, this is the wrong component.
- There is only one action. Use a Button.
- The extra actions are unrelated to the main one. Pair a primary Button with an overflow Icon Button instead.
- The list is long. A long list belongs in a plain menu, where nothing pretends to be the default.
- The default is a destructive action. Destructive options go in the menu,
last, with
intent="error".
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, xl being the 96px hero step. xs and sm grow to the 44px
touch floor on touch devices, 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.
Why it works this way: the label is the only thing distinguishing this control's two segments, so hiding it behind a spinner would leave two targets with nothing to read. 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…". That reads only because the label survives.
States
Disabled and loading. disabled takes both halves; loading takes only the
action.
Skeleton
SplitButtonSkeleton is the control's silhouette: an action block sized by the
label's length in characters (chars) and the real square menu half, at the
same size, corners and touch floor. Pass the real control's dir too — it
mirrors the halves and picks the language the placeholder announces "Loading"
in.
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.
RTL
With dir="rtl" the action half sits on the right, the chevron on the left, and the default align="end" opens the menu flush with the left edge. The menu portals into the page body, so set dir on the control rather than relying on an ancestor.
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" | Mirror the real control's size — both halves, touch floors included |
rounded | ButtonRounded | "full" | Mirror the real control's corners; the join stays square |
actionWidth | string | "w-[8ch]" | Width class of the label placeholder |
chars | number | — | Label length in characters; overrides actionWidth |
dir | "ltr" | "rtl" | — | Reading direction: mirrors the halves and picks the language of the "Loading" announcement. Pass the real control's dir |
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 mouse scale. On a touch device — any screen under 768px, or a
coarse pointer at any width, tablets included — 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 — 1.95–2.64:1 against its own segment fill on the neutral fills, lower on
an intent fill — and treated as decorative reinforcement: each half is
identifiable by its own label or chevron, so the seam carries no information a
user could lose. The chevron sits at 60% ink on the neutral fills and at its
label's full ink on an intent fill, and clears the 3:1 non-text threshold on
every variant, intent and surface, in both themes. 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.