Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Last run: —

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.

Split button vs. plain menu

The test is whether a default exists. If one option is chosen 90% of the time, the split saves a click on almost every interaction. If usage is evenly spread, the split invents a default and makes four of five users click twice.

Split buttonDeploy to production: 91% of runs
Split buttonNo option above 30% of runs

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.

WrongA mis-tap deletes the project
RightDestructive actions live in a menu behind a confirm

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.

Default
With icon
Outlined
Tonal
Disabled
Large
Two options
Danger item

Anatomy

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.

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

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

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

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

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

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
divider insetTop and bottom inset of the seam
menu offsetGap between control and menu

Radius

TokenValueUsed for
--radius-mdOuter corners only

Shadow

TokenValueUsed for
--shadow-e4—Menu elevation

Motion

TokenValueUsed for
--duration-fastChevron rotation and menu entrance

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingIconTypeMin widthMax widthWhen to use
Small32px0 10px primary15px12px28px chevron—Table toolbars and card headers.
Medium36px0 14px primary16px13px32px chevron—The default. Page-level primary actions.
Large44px0 18px primary18px15px40px chevron—Touch-first layouts and empty-state calls to action.
Menu————13rem20remWide enough for the longest option without truncation; capped so a long label wraps instead of stretching the panel.
Menu row30px0 10px————44px on coarse pointers. Two-line rows go to 44px everywhere.

Do

Make the primary label name the exact default"Deploy" beside a menu containing three deploys tells the user nothing about what the wide half will do. "Deploy to production" removes the guess entirely.
aria-label="More deploy options"
aria-haspopup="menu" aria-expanded="false"
Give the chevron half its own accessible name"More deploy options" ties the two halves together for a screen-reader user. A bare "Toggle" or an unnamed button leaves them with an unexplained control immediately after the action.
Remembered from last time
Promote the last-used option to the defaultIf someone deploys to staging three times running, the fourth click should not need the menu. Persist the choice per user and per surface, and label the primary with whatever it now runs.
Keep destructive items inside the menu, markedA danger item in the menu is fine — it takes two deliberate actions to reach. The same item as the default is one mis-tap away.

Don't

Do not use one when there is no real defaultThe control invents a winner. If four export formats are used evenly, three users out of four now click twice and the fourth exports the wrong format by accident.
Do not make the default destructiveThe chevron is 32px wide and flush against the primary. Every mis-aimed tap on a phone runs the default, and "Delete" is not something to run on a mis-aim.

Whole control opens a menu — this is a Menu, not a split button

Do not open the menu from the primary halfThen it is a menu button with a decorative label, and a user who wanted the default action gets a menu they have to read. The two halves must do two different things.
Do not put unrelated actions in the menuThe menu holds variants of the primary verb. "Deploy" next to "Delete branch" and "Invite teammate" is a kebab menu that has been welded onto an action for no reason.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

2.1.1KeyboardA2.4.3Focus OrderA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabStops on the primary half, then on the chevron half. Two controls, two stops — never collapse them into one.
Enter / SpaceOn 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.
EscCloses the menu and returns focus to the chevron half — never to the body, and never to the primary.
Home / EndJumps 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.
AttributeApplied toNotes
aria-haspopup="menu"The chevron halfAnnounces that this half opens something rather than doing something.
aria-expandedThe chevron halfTracks the menu’s open state. Static "false" is a common and silent bug.
aria-labelThe chevron halfMust reference the primary: "More deploy options". Without it the second control is anonymous.
role="menu" / "menuitem"The panel and its rowsOnly if arrow keys move focus between items. If the panel holds arbitrary interactive content, it is a Popover, not a menu.
aria-controlsThe chevron halfPoints at the menu’s id, so assistive tech can associate the two.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
startIconReactNode—Leading glyph on the primary half only.
disabledbooleanfalseDisables both halves. Disabling only one is always a bug.

Notes

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.