Split button

Split button

A lead action and a trailing half that opens a menu of related ones, in five sizes.

A split button is one action you will take most of the time, next to a menu of the ones you might take instead — Save and “save as”, Reply and “reply all”, Export and every other format.

The root is a <div class="split-button"> holding two buttons: the leading half does the work, and the trailing half is a menu-trigger that opens a <menu>. The pair sits 2dp apart, the outside is round, and the seam between them is square-ish until something happens to it.

<div class="split-button">
  <button class="button">Save</button>
  <button class="button menu-trigger" data-target="save-menu" aria-label="More save options">
    <span class="material-symbols" aria-hidden="true">arrow_drop_down</span>
  </button>
  <menu id="save-menu">
    <li><a href="#!">Save a copy</a></li>
    <li><a href="#!">Save as template</a></li>
    <li><a href="#!">Export as PDF</a></li>
  </menu>
</div>

The trailing half is an ordinary Menu trigger, so everything Menu does it does here: Expressive.AutoInit() starts it, data-target names the surface, and the menu's own keyboard model, alignment and options are unchanged. This component adds no script of its own.

The menu sizes itself to its own content here. Menu normally matches the surface to its trigger, but a trigger narrower than the surface's own 112dp minimum is not a constraint it can honour — and this trigger is a chevron in a 48dp box. Items still wrap at the menu's 280dp maximum.

Both halves are buttons, so every style class works on them — but use the same one on both, or the pair stops reading as one shape. A tonal split button is the usual choice when the leading action is not the page's primary one.

  • Reply all
  • Forward
  • Reply all
  • Forward
  • Activation

    Opening the menu does two things to the trailing half: the chevron turns over, and the seam swells to fully round, so the pair reads as two separated buttons for as long as the menu is up. Both are drawn from aria-expanded, which Menu writes — there is nothing to toggle yourself, and nothing to author. Open the demo above and watch the corner.

    Hovering or pressing either half swells that half's inner corner part of the way, which is Material's own ladder: 8dp at xsmall, 12dp through medium, 20dp at the two largest sizes. All of it stops under prefers-reduced-motion.

    Sizes

    Five sizes, on the container: xsmall, small (the default), medium, large and xlarge — 32, 40, 56, 96 and 136dp tall, the same ladder as a common button. The class goes on the split-button, never on a half: the container sizes both, and the seam, the insets and the chevron all move with it.

  • One
  • Two
  • One
  • Two
  • The seam widens with the size — 4dp through medium, 8dp at large, 12dp at xlarge. The two largest rungs are for a split button that is the only thing on a screen.

  • One
  • Two
  • One
  • Two
  • <div class="split-button medium">
      <button class="button tonal">Medium</button>
      <button class="button tonal menu-trigger" data-target="size-menu" aria-label="More options">
        <span class="material-symbols" aria-hidden="true">arrow_drop_down</span>
      </button>
      <menu id="size-menu">
        <li><a href="#!">One</a></li>
        <li><a href="#!">Two</a></li>
      </menu>
    </div>

    The trailing half is narrower than the leading one and its chevron is drawn larger than a button icon of the same size — 22dp against 20 at the bottom of the ladder, 50dp against 40 at the top. That keeps it 48dp wide at the two smallest sizes, which is a touch target, and square at the three largest.

    Semantics

    The container declares no role. A composite role such as toolbar promises arrow-key navigation, and this component rejects it rather than withholding it: the two halves are independent commands reached with Tab. The one composite widget here is the <menu>, which carries its own role and its own keyboard model.

    The trailing half's chevron is decoration, so it is aria-hidden="true" and the button carries an aria-label of its own — “More save options”, not “More”. Name it for what the menu contains, since a screen reader hears it right after the leading half's label.

    aria-expanded is Menu's to write. Do not author it: the constructor stamps it on the trigger and every open and close rewrites it, and the expanded shape above is drawn from it — an authored "true" draws an open split button over a closed menu.

    Both halves are controls, written as direct children: a <button>, or an <a class="button" href> when it navigates. A wrapper element is refused — the seam, the insets and the larger chevron are all written against direct children. So is an icon-button: that is its own component, setting its height, insets, colours and icon sizing on the element itself, so it reaches none of this geometry and fights the seam it is handed. The trailing half is a <button class="button menu-trigger">.

    Order matters, and only the markup states it. The halves are told apart by role rather than by position — which is what lets you write the <menu> inside the container if you like — so nothing in the sheet notices a trigger written first. It would take the trailing side's round outer corners while flex put it on the leading side: a split button with its seam and its round ends swapped. Lead action first, trigger second, menu last if it is in there at all. Exactly two controls, and the count is held from both ends: a third gets the leading half's rules too, and a container holding only the trigger leaves it squared off along a seam with nothing to join to. One control that opens a menu is just a menu trigger — drop the container. Several peer actions are a button group, and several related ones belong in the menu.

    Tokens

    Set these on the container, or in a rule of your own that picks out the split buttons you want to change. The values below are the defaults — a small split button; the size classes rewrite all but the first two.

    Token Default
    --md-comp-split-button-between-space2px
    --md-comp-split-button-container-shape9999px
    --md-comp-split-button-leading-button-trailing-space12px
    --md-comp-split-button-trailing-button-space13px
    --md-comp-split-button-trailing-icon-size22px
    --md-comp-split-button-inner-corner-corner-size4px
    --md-comp-split-button-inner-pressed-corner-corner-size12px

    Height, the leading half's outer inset, the outline width and every colour come from the button tokens, which the size class sets on the container for both halves. Material states a separate leading and trailing inset for the trailing half; they are equal in all five sizes, so one token carries both — as it does for the hovered and pressed seam, which Material also gives the same value.

  • Source color

    The seed every generated ramp derives from. Pick one and browse the docs — the whole theme follows. Error does not: it is a fixed hue.