Skip to content

Actions

Split button

A button for the main action joined to a menu button for related actions.

Examples

Split buttons

The leading button runs the main action; the trailing one opens a menu of related ones. While the menu is open its inner corners round fully and the icon turns.

The live demo runs with JavaScript. Its code is below.

React HTML
import { Icon, Menu, MenuItem, SplitButton } from '@kyralabs/m3exp-web/react';

export default function Split() {
  return (
    <>
      <SplitButton
        icon={<Icon name="send" />}
        menuLabel="More send options"
        onPress={() => {
          console.log('send');
        }}
        menu={
          <Menu aria-label="Send options">
            <MenuItem id="schedule" icon="schedule">
              Schedule send
            </MenuItem>
            <MenuItem id="draft" icon="draft">
              Save draft
            </MenuItem>
          </Menu>
        }
      >
        Send
      </SplitButton>
      <SplitButton
        variant="tonal"
        size="m"
        menuLabel="More edit options"
        menu={
          <Menu aria-label="Edit options">
            <MenuItem id="rename">Rename</MenuItem>
            <MenuItem id="duplicate">Duplicate</MenuItem>
          </Menu>
        }
      >
        Edit
      </SplitButton>
    </>
  );
}
<m3e-split-button label="Send" icon="send" menu-label="More send options">
  <m3e-menu>
    <m3e-menu-item value="schedule" icon="schedule">Schedule send</m3e-menu-item>
    <m3e-menu-item value="draft" icon="draft">Save draft</m3e-menu-item>
  </m3e-menu>
</m3e-split-button>
<m3e-split-button label="Edit" variant="tonal" size="m" menu-label="More edit options">
  <m3e-menu>
    <m3e-menu-item value="rename">Rename</m3e-menu-item>
    <m3e-menu-item value="duplicate">Duplicate</m3e-menu-item>
  </m3e-menu>
</m3e-split-button>

Run the HTML version (custom elements, no framework).

Usage

Primary action plus a menu of related actions. Sizes XS–XL.

API

React

<SplitButton>

Material 3 Expressive split button (COMPONENT_SPEC §4; Compose SplitButtonLayout with SplitButtonDefaults.LeadingButton and the checkable TrailingButton): the leading button runs onPress, the trailing button opens menu (a <Menu>, via <MenuTrigger>: press, ArrowDown / ArrowUp / Enter / Space, Escape returns focus to it). While the menu is open the trailing button is checked: its inner corners morph to full and its icon turns 180°, on interruptible springs.

Prop Type Default Description
variant "elevated" | "filled" | "tonal" | "outlined" filled Color configuration of both buttons. Default filled.
size "xs" | "s" | "m" | "l" | "xl" s Default s (40dp).
icon ReactNode Leading button icon, e.g. <Icon name="save" />.
children ReactNode Leading button label.
menu required ReactElement The <Menu> the trailing button opens.
menuLabel required string Accessible name of the trailing (menu) button, e.g. "More save options".
trailingIcon ReactNode <Icon name="keyboard_arrow_down" /> Trailing icon. Default <Icon name="keyboard_arrow_down" /> (Compose samples).
menuPosition "start" | "end" | "below" | "above" | "left" | "right" below Where the menu opens. Default below (flips above when there is no room).
isDisabled boolean Disables both buttons.
isOpen boolean
defaultOpen boolean
onPress (e: PressEvent) => void The primary action (leading button).
onOpenChange (isOpen: boolean) => void

Also accepts HTML attributes not listed here.

Custom elements

<m3e-split-button>

import '@kyralabs/m3e-elements/split-button.js';

<m3e-split-button label="Save" icon="save" menu-label="More save options" variant="tonal" size="m"> (COMPONENT_SPEC §4). Renders the m3e-css .m3e-split-button contract inside itself (the host is display: contents): a leading .m3e-button for the primary action and a trailing menu button, with the same variant and size. Load button.css and split-button.css.

  • The trailing button opens the <m3e-menu> child (or the one whose id menu names): it becomes that menu's anchor, so the menu handles the press, ArrowDown / ArrowUp / Enter / Space, Escape (focus returns to the trailing button), light dismiss and aria-haspopup / aria-expanded / aria-controls. Import @kyralabs/m3e-elements/menu.js for it.
  • Open (aria-expanded="true") is the trailing button's checked state: its inner corners morph to full and its icon turns 180°.
  • variant: filled (default) | tonal | elevated | outlined. size: xs | s (default) | m | l | xl. icon: leading Material Symbols name. trailing-icon: default keyboard_arrow_down. menu-label: the trailing button's accessible name (required). disabled disables both buttons.
  • Motion: the inner corners (press, open) on the default effects spring and the icon rotation on the samples' spring, both interruptible with their velocity kept (m3e-motion springs).
  • Events: press (leading button activated, detail: {}); menu events bubble from the menu.
Attribute Property Type Notes
variant variant string
size size string
label label string
icon icon string
trailing-icon trailingIcon string
menu-label menuLabel string
menu menu string
disabled disabled boolean
Event Detail Description
press EmptyDetail The leading button was pressed.

CSS

split-button.css

@import '@kyralabs/m3e-css/split-button.css';

Split button (COMPONENT_SPEC §4): a leading button for the primary action and a trailing menu button, 2dp apart. Both are .m3e-button (button.css: load it too) with the same variant and size modifiers; this file shapes them. m3e-elements (<m3e-split-button>) and m3e-react (<SplitButton>) wire the menu and add interruptible springs on the same classes.

<div class="m3e-split-button">
  <button type="button" class="m3e-button m3e-button--filled m3e-button--size-m
      m3e-button--round m3e-split-button__leading">
    <span class="m3e-icon" aria-hidden="true">edit</span> Edit
  </button>
  <button type="button" class="m3e-button m3e-button--filled m3e-button--size-m
      m3e-button--round m3e-split-button__trailing" aria-label="More edit options"
      aria-haspopup="true" aria-expanded="false">
    <span class="m3e-icon" aria-hidden="true">keyboard_arrow_down</span>
  </button>
</div>

Variants (on both buttons): m3e-button--filled (default) --tonal --elevated --outlined. Sizes: m3e-button--size-xs, (s), --size-m, --size-l, --size-xl. aria-expanded="true" on the trailing button is the checked state (menu open).

20 custom properties it reads
Custom property Default
--m3e-comp-split-button-center-optically-coefficient 0.11
--m3e-comp-split-button-large-inner-corner-corner-size var(--m3e-sys-shape-corner-value-small)
--m3e-comp-split-button-large-trailing-icon-size 38px
--m3e-comp-split-button-leading-button-min-width 48px
--m3e-comp-split-button-medium-inner-corner-corner-size var(--m3e-sys-shape-corner-value-extra-small)
--m3e-comp-split-button-medium-trailing-icon-size 26px
--m3e-comp-split-button-shape-duration var(--m3e-sys-motion-spring-default-effects-duration)
--m3e-comp-split-button-shape-easing var(--m3e-sys-motion-spring-default-effects-easing)
--m3e-comp-split-button-small-inner-corner-corner-size var(--m3e-sys-shape-corner-value-extra-small)
--m3e-comp-split-button-small-trailing-icon-size 22px
--m3e-comp-split-button-spacing 2px
--m3e-comp-split-button-trailing-button-min-width 48px
--m3e-comp-split-button-trailing-button-state-layer-opacity-checked var(--m3e-sys-state-pressed-state-layer-opacity)
--m3e-comp-split-button-trailing-icon-rotation-checked 180deg
--m3e-comp-split-button-trailing-icon-rotation-duration 319ms
--m3e-comp-split-button-trailing-icon-rotation-easing linear(0, 0.0007 0.31%, 0.0028 0.63%, 0.0062 0.94%, 0.0165 1.57%, 0.0232 1.88%, 0.0391 2.51%, 0.0582 3.13%, 0.0797 3.76%, 0.1157 4.7%, 0.1548 5.64%, 0.196 6.58%, 0.3236 9.4%, 0.3791 10.66%, 0.4327 11.91%, 0.4836 13.17%, 0.5432 14.73%, 0.5872 15.99%, 0.6281 17.24%, 0.6657 18.5%, 0.7002 19.75%, 0.739 21.32%, 0.7735 22.88%, 0.804 24.45%, 0.8307 26.02%, 0.8584 27.9%, 0.8782 29.47%, 0.8987 31.35%, 0.9158 33.23%, 0.9324 35.42%, 0.9459 37.62%, 0.9567 39.81%, 0.9666 42.32%, 0.9743 44.83%, 0.9809 47.65%, 0.9863 50.78%, 0.9935 57.68%, 0.9975 66.46%, 0.9993 78.37%, 1)
--m3e-comp-split-button-x-large-inner-corner-corner-size var(--m3e-sys-shape-corner-value-medium)
--m3e-comp-split-button-x-large-trailing-icon-size 50px
--m3e-comp-split-button-x-small-inner-corner-corner-size var(--m3e-sys-shape-corner-value-extra-small)
--m3e-comp-split-button-x-small-trailing-icon-size 22px

Accessibility

two buttons; trailing aria-haspopup="true" (equivalent to "menu" in WAI-ARIA 1.2; what the APG menu button and React Aria's useMenuTrigger use, as Menus §21 does) aria-expanded aria-label; Down arrow opens on the first item (Up on the last, Enter/Space on the first); Esc closes and returns focus to the trailing button.

the trailing button toggles the menu and reflects open state (aria-expanded, checked shape and icon).

Every interactive part has a target of at least 48 × 48 px, a visible focus ring for keyboard focus only, and works under forced-colors and prefers-reduced-motion.

Differences from Compose

the menu is placed from the trailing button (the Compose sample anchors its DropdownMenu to the box around the whole layout, so it lines up with the leading button); springs keep their velocity on every retarget (Compose's AnimatedShapeState only on a reversal); XS/S reach 48dp with the button's ::after target instead of reserving layout space (minimumInteractiveComponentSize); the samples' trailing tooltip waits for Tooltips (§33).

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §4 and the m3e-css sources.

SplitButton.kt, SplitButton*Tokens.kt, HorizontalCenterOptically.kt, internal/AnimatedShape.kt, SplitButtonSamples.kt; m3.material.io/components/split-button/overview; Material Web labs split button (2.5.0, secondary).

SplitButton{XSmall,Small,Medium,Large,XLarge}Tokens.kt@19991f2c plus the SplitButtonDefaults values Compose renders (components/split-button-defaults.css; SOURCES register #22).

  • Spacing 2dp at every size (SplitButtonDefaults.Spacing = the small BetweenSpace). Minimum width 48dp for both buttons (LeadingButtonMinWidth). Outer corners full (OuterCornerSize = ShapeDefaults.CornerFull). Leading icon size, icon–label space and label style follow the button of the same size (ButtonDefaults.iconSizeFor / iconSpacingFor / textStyleFor, as the samples pass them); content padding is horizontal only.
  • Checked (menu open) trailing button: TrailingCheckedShape = CircleShape (inner corners full; the tokens' TrailingInnerSelectedCornerCornerSizePercent 50) and an outline in the content color at StateTokens.PressedStateLayerOpacity (0.1). Pressed wins over checked (shapeByInteraction).
  • The trailing content is centered optically: shifted toward the end by CenterOpticallyCoefficient (0.11) × (start corners − end corners) (HorizontalCenterOptically.kt), 0 when checked.
  • Compose renders no hover morph: the InnerHoveredCornerCornerSize tokens are unused, here too.
split-button.css sources

(compose/material3 @ 19991f2c; lines: m3e-tokens PARITY.md):

  • SplitButton{XSmall,Small,Medium,Large,XLarge}Tokens: heights, leading/trailing paddings, inner corner sizes (resting, pressed), trailing icon size, the 50% selected inner corner.
  • SplitButton.kt (split-button-defaults.css): SplitButtonLayout spacing (SplitButtonDefaults. Spacing = the small BetweenSpace at every size), 48dp minimum widths, outer corners = ShapeDefaults.CornerFull, TrailingCheckedShape = CircleShape (inner corners morph to full when checked), the checked trailing button's outline in the content color at StateTokens.PressedStateLayerOpacity, and the inner corner morph on the default effects spring (rest → pressed → checked; Compose has no hover morph, so the *HoveredCornerCornerSize tokens are unused here too). Leading icon size and spacing, label style and colors are the button's (ButtonDefaults.iconSizeFor / iconSpacingFor / textStyleFor and the Button colors, as the samples pass them). Content padding is horizontal only (contentPadding start/end).
  • HorizontalCenterOptically.kt: the trailing content shifts by CenterOpticallyCoefficient × (start corners − end corners) toward the end, so the icon looks centered in the asymmetric shape; the shift follows the morph and is 0 when checked.
  • SplitButtonSamples.kt: the trailing icon rotates 180° when checked on animateFloatAsState's default spring() (damping 1, stiffness 1500; split-button-defaults.css).
  • Touch target: each button keeps button.css's ::after (≥ 48dp tall at XS/S); widths are ≥ 48dp.