Actions
Split button
A button for the main action joined to a menu button for related actions.
- COMPONENT_SPEC §4
- CSS · custom elements · React
- Open in the playground
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.
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 idmenunames): it becomes that menu'sanchor, so the menu handles the press, ArrowDown / ArrowUp / Enter / Space, Escape (focus returns to the trailing button), light dismiss andaria-haspopup/aria-expanded/aria-controls. Import@kyralabs/m3e-elements/menu.jsfor 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: defaultkeyboard_arrow_down.menu-label: the trailing button's accessible name (required).disableddisables 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'TrailingInnerSelectedCornerCornerSizePercent50) and an outline in the content color atStateTokens.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
InnerHoveredCornerCornerSizetokens 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.