Split Button
One obvious default plus a menu of alternatives, in a single control. It exists because the fifth button in a toolbar is always the one nobody uses.
Also called Menu Button, Dropdown Button — in this system all of them are Split Button.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The three shapes it takes
Filled for a page-level primary, outlined for a secondary, elevated where the control sits on a busy surface and needs its own edge. The variant is the same decision as for any other button — hierarchy first, the split second.
Never split a destructive default
The chevron is a 32px target sitting flush against the primary. Every mis-tap runs the default. If the default deletes something, the control is a trap regardless of how careful the user is.
Sizes
The chevron half keeps its width across sizes — it is sized by the touch target, not by the label beside it.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
The wide primary half runs the default action; the narrow half opens the menu. They share a height and a fill but never a click handler.
- Primary halfSized by its label
Runs the default immediately. Its label must name that specific action — "Deploy to production", not "Deploy" — because the menu underneath contains the other deploys.
- Divider1px at 24% alpha
Full height, inset by 6px top and bottom on filled variants. It is the only thing telling the user there are two targets here, so it must never be decorative-only contrast.
- Chevron half32px (44px on touch)
Sized by the touch target rather than by the icon. This is the narrowest target in the system that still triggers something consequential next to it.
- Chevron14px, rotates 180°
Rotation on open is what confirms the click landed on the menu half rather than the action half. Without it a slow menu feels like a missed click.
- Menu offset6px below, right-aligned
Aligned to the control’s right edge rather than the chevron’s, so a wide menu does not hang off the layout. Far enough down that the focus ring is not clipped.
- RadiusOuter corners only
Left corners on the primary, right corners on the chevron, square at the seam — exactly the button-group rule, for exactly the same reason.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-accent | — | Filled background, both halves |
| --ds-fg-on-accent | — | Label and chevron on a filled control |
| --ds-border | — | Outlined variant’s edge |
| --ds-layer-hover | — | Hover on either half, independently |
| --ds-surface-overlay | — | Menu surface |
| --ds-danger-text | — | A destructive item inside the menu |
| --ds-focus-ring | — | Focus outline on whichever half has focus |
Spacing
| Token | Value | Used for |
|---|---|---|
| divider inset | Top and bottom inset of the seam | |
| menu offset | Gap between control and menu |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Outer corners only |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Menu elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Chevron rotation and menu entrance |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Icon | Type | Min width | Max width | When to use |
|---|---|---|---|---|---|---|---|
| Small | 32px | 0 10px primary | 15px | 12px | 28px chevron | — | Table toolbars and card headers. |
| Medium | 36px | 0 14px primary | 16px | 13px | 32px chevron | — | The default. Page-level primary actions. |
| Large | 44px | 0 18px primary | 18px | 15px | 40px chevron | — | Touch-first layouts and empty-state calls to action. |
| Menu | — | — | — | — | 13rem | 20rem | Wide enough for the longest option without truncation; capped so a long label wraps instead of stretching the panel. |
| Menu row | 30px | 0 10px | — | — | — | — | 44px on coarse pointers. Two-line rows go to 44px everywhere. |
aria-label="More deploy options"
aria-haspopup="menu" aria-expanded="false"Whole control opens a menu — this is a Menu, not a split button
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The seam between the halves must reach 3:1 against the fill. It is the only visual signal that two targets exist, so it is a meaningful boundary under 1.4.11.
- The chevron must reach 3:1 against the fill it sits on. At 14px inside a saturated filled button this is easy to get wrong with a low-alpha white.
- Hover must affect only the half under the pointer. A wash across both halves erases the seam at the exact moment the user is deciding which one to hit.
Keyboard
| Tab | Stops on the primary half, then on the chevron half. Two controls, two stops — never collapse them into one. |
| Enter / Space | On the primary, runs the default. On the chevron, opens the menu and focuses its first item. |
| ↓ | On the chevron, opens the menu and moves to the first item. On the primary, does nothing. |
| ↑ / ↓ | Moves between menu items, wrapping at the ends. |
| Esc | Closes the menu and returns focus to the chevron half — never to the body, and never to the primary. |
| Home / End | Jumps to the first or last menu item. |
Screen readers
- The pair announces as "Deploy to production, button" then "More deploy options, menu button, collapsed".
- When the menu opens, announce the item count: "menu, 3 items". Without it a user has to arrow to the end to learn how long the list is.
- If the default is remembered from last time, say so in the accessible name or a nearby live region — a label that silently changed between visits is disorienting.
Focus & touch
- Opening the menu moves focus into it. Closing returns focus to the chevron half. Selecting an item closes the menu, runs the action, and returns focus to the chevron — from where Tab continues naturally rather than restarting at the top of the page.
- The chevron half must be at least 44px wide on coarse pointers, which usually means the whole control grows rather than the primary shrinking. On narrow phones, prefer a full-width primary button with a separate "More options" row underneath — a 32px chevron beside a destructive-adjacent action is the worst target in any mobile layout.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-haspopup="menu" | The chevron half | Announces that this half opens something rather than doing something. |
| aria-expanded | The chevron half | Tracks the menu’s open state. Static "false" is a common and silent bug. |
| aria-label | The chevron half | Must reference the primary: "More deploy options". Without it the second control is anonymous. |
| role="menu" / "menuitem" | The panel and its rows | Only if arrow keys move focus between items. If the panel holds arbitrary interactive content, it is a Popover, not a menu. |
| aria-controls | The chevron half | Points at the menu’s id, so assistive tech can associate the two. |
Example usage
1import { SplitButton } from '@/ui/Button'23<SplitButton4 label="Deploy to production" // names the DEFAULT, not the family5 startIcon={<Rocket />}6 onAction={deployToProduction} // the wide half — one click, no menu7 options={[8 { label: 'Deploy to staging', icon: <GitBranch />, onSelect: deployStaging },9 { label: 'Deploy and watch logs', icon: <Rocket />, onSelect: deployVerbose },10 { label: 'Schedule for 02:00', icon: <Clock />, onSelect: schedule },11 ]}12/>1314// Remembering the last choice is what makes the control pay for itself.15const [preferred, setPreferred] = usePersistentState('deploy:default', 'production')16const target = TARGETS.find((t) => t.id === preferred)!1718<SplitButton19 label={`Deploy to ${target.name}`}20 onAction={() => deploy(target.id)}21 options={TARGETS.filter((t) => t.id !== preferred).map((t) => ({22 label: `Deploy to ${t.name}`,23 onSelect: () => {24 setPreferred(t.id) // promote it, so the next click needs no menu25 deploy(t.id)26 },27 }))}28/>Framework-free HTML
<div class="ds-split">
<!-- Two buttons. Two names. Two tab stops. -->
<button type="button" class="ds-split__action">
<svg aria-hidden="true">…</svg>
Deploy to production
</button>
<button
type="button"
class="ds-split__toggle"
aria-label="More deploy options"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="deploy-menu"
>
<svg aria-hidden="true">…</svg>
</button>
<div id="deploy-menu" role="menu" aria-label="More deploy options" hidden>
<button type="button" role="menuitem">Deploy to staging</button>
<button type="button" role="menuitem">Deploy and watch logs</button>
<button type="button" role="menuitem">Schedule for 02:00</button>
</div>
</div>CSS
.ds-split {
display: inline-flex;
isolation: isolate;
}
.ds-split__action {
border-start-end-radius: 0;
border-end-end-radius: 0;
padding-inline: 14px;
}
.ds-split__toggle {
inline-size: 32px; /* sized by the target, not the icon */
border-start-start-radius: 0;
border-end-start-radius: 0;
display: grid;
place-items: center;
}
/* The seam is the only signal that there are two targets here, so it is a
meaningful boundary and owes 3:1 — not a decorative hairline. */
.ds-split__toggle::before {
content: '';
position: absolute;
inset-block: 6px;
inset-inline-start: 0;
inline-size: 1px;
background: color-mix(in oklab, currentColor 24%, transparent);
}
/* Hover only the half under the pointer. A wash across both erases the seam
at the exact moment the user is choosing which side to hit. */
.ds-split__action:hover,
.ds-split__toggle:hover { background: var(--ds-layer-hover); }
.ds-split__action:focus-visible,
.ds-split__toggle:focus-visible { z-index: 1; }
.ds-split__toggle[aria-expanded='true'] svg { transform: rotate(180deg); }
@media (pointer: coarse) {
.ds-split__toggle { inline-size: 44px; }
}Component API
SplitButton
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | The default action’s own name — not the family name. Shown on the primary half. |
| onAction* | () => void | — | Runs on the primary half. Must never be destructive. |
| options* | MenuItemSpec[] | — | Two to six variants of the same verb. Each may carry an icon, a shortcut, and a danger flag. |
| variant | 'filled' | 'outlined' | 'elevated' | 'filled' | Hierarchy, decided exactly as for a plain Button. |
| size | 'sm' | 'md' | 'lg' | 'md' | The chevron half keeps a touch-sized width at every size. |
| startIcon | ReactNode | — | Leading glyph on the primary half only. |
| disabled | boolean | false | Disables both halves. Disabling only one is always a bug. |
Professional tips
- Cap the menu at six items. Past that the control is a toolbar overflow wearing a default, and the default is usually wrong for most of the list.
- Show the keyboard shortcut for the default in the menu row that matches it, so the user learns they never needed the control at all.
- If the primary half is disabled for a reason, put the reason in a tooltip on the wrapper. A disabled split button with no explanation is two dead targets.
- Do not animate the primary label when the remembered default changes — swap it on open, not while the pointer is over it, or the user clicks something they did not read.
Performance
- Render the menu only when open. A page with twenty split buttons in a table that each mount a hidden panel is twenty popovers of layout work nobody sees.
- Compute the menu’s placement on open, not on every scroll frame. Anchoring math in a scroll handler is the classic cause of jank in dense toolbars.
- Keep the chevron rotation on transform. Animating anything that affects layout inside a joined control makes the seam visibly shift.
Common mistakes
- Labelling the primary with the family verb ("Deploy") so nobody can tell what the wide half will actually do.
- Leaving aria-expanded hardcoded to false, so assistive tech never learns the menu opened.
- Giving the chevron half no accessible name, producing an anonymous button immediately after the action.
- Making the whole control open the menu, which turns a split button into a mislabelled Menu.
- Returning focus to the body after the menu closes, dropping a keyboard user back at the top of the page.
- A 32px chevron on touch, where every miss runs the primary action.
Real-world recommendations
- The pattern earns its keep when the default is above roughly 70% of use. Below that, measure again — you will usually find a plain button plus a separate menu tests better.
- IDEs and CI tools are where this control is strongest: run configurations are a textbook 90/10 split, and the saved default matches how people actually work.
- Email clients get it wrong constantly. "Send" with a "Send later" chevron is fine; "Send" with "Discard draft" in the menu means a stray click can lose work.
- On mobile, most teams end up replacing the split with a full-width primary and a text link. It is less elegant and measurably less error-prone.