Split button
components specs/components/split-button.kmd
Material 3 Expressive composite button: primary action + separate menu trigger side-by-side, divided. Trailing chevron rotates + shape morphs when menu opens. 5 sizes × 4 styles. Anchored to host `buttons.kmd`.
When this spec applies
Primary triggers
- Build a button with primary action + alternate options menu
All triggers
- Action with a default + 2-3 secondary variants under a menu
- Save (default) + Save as / Export / etc.
Specification body
Spec — Split button
Companion:
buttons.kmd(base button variants). Trailing morph viamotion.kmdR9 +shape-library.kmdR2.
Princípios
- Two distinct actions — primary tap = default action; trailing tap = open menu. NEVER one combined.
- Visual separation — divisor line entre leading + trailing.
- Shape morph on menu open — trailing morphs (square → pill) + chevron rotates 180°.
- 5 sizes, 4 styles — XS/S/M/L/XL × elevated/filled/tonal/outlined.
R1 — Anatomia
┌──────────────────┬─────┐
│ Save │ ▼ │ ← leading (primary action) | divisor | trailing (menu)
└──────────────────┴─────┘
╱
╱ menu opens →
┌────────────────┐
│ Save as... │
│ Export │
│ Save & close │
└────────────────┘
Slots:
| Slot | Function |
|---|---|
| Leading | Primary action (text + optional icon). Tap = executa default. |
| Divisor | 1dp line; color outline-variant per color-roles.kmd. |
| Trailing | Menu trigger (chevron ▼). Tap = abre menu (cross-link menus.kmd). |
Both slots are focusable nodes (a11y separately reachable).
R2 — Sizes
| Size | Height | Padding (L+R per slot) | Min width (leading) | Min width (trailing) |
|---|---|---|---|---|
| XS | 28 | 8 | 60 | 28 |
| S | 32 | 10 | 72 | 32 |
| M | 40 | 16 | 88 | 40 |
| L | 48 | 20 | 104 | 48 |
| XL | 56 | 24 | 120 | 56 |
Trailing min-width ensures chevron + tap target ≥ 28dp (S+).
R3 — Styles
Same as buttons.kmd base variants:
| Style | Background | Border | Elevation |
|---|---|---|---|
| Elevated | surface tint | none | shadow + tonal |
| Filled | primary | none | none |
| Tonal | secondary-container | none | none |
| Outlined | transparent | outline 1dp | none |
Both slots share the same style (no mixed-style split buttons).
R4 — Menu open state morph
When user taps trailing → menu opens:
- Shape morph trailing: from square corner → pill corner (per
shape-library.kmdPill ↔ Squircle morph). - Chevron rotation:
▼rotates 180° →▲via springmotion-spatial-fast. - Trailing color shift: optional tint to indicate active state (per preset).
- Menu appears: cross-link
menus.kmdfor menu styling.
On menu close (selection OR ESC OR outside-tap): reverse morph.
R5 — Surface bindings
| Surface | API |
|---|---|
| Flutter | KoderSplitButton({onPrimary, menuItems, size, style}) em koder_kit/ (futuro) |
| Web | <koder-split-button size="md" style="filled"> em koder_web_kit |
| Compose Android | KoderSplitButton (futuro) |
| SwiftUI iOS | idem (futuro) |
| CLI / TUI | Plain action; menu via slash command (<action> default, <action>:menu to open) |
R6 — Acessibilidade
- Leading:
role="button" aria-label="<primary action>". - Trailing:
role="button" aria-haspopup="menu" aria-expanded="<state>" aria-label="<primary action> options". - Keyboard: Tab cycles leading → trailing; Space/Enter on each.
- Arrow Down on trailing also opens menu.
- ESC closes menu; focus returns to trailing.
- Menu items per
menus.kmdR6.
R7 — i18n
Inherits from buttons.kmd + menus.kmd. Trailing label localized as
"
| Key | en-US | pt-BR |
|---|---|---|
split_button.trailing.label | "{action} options" | "Opções de {action}" |
R8 — Reduced-motion
Shape morph + chevron rotation: instant (no spring). Menu open instant (no slide).
R9 — Per-preset variation
| Preset | Split button style |
|---|---|
material3 / material_expressive | Default morph + rotation |
material2 | No morph; chevron rotation only |
terminal_classic | [action ▾] text-only; trailing literal ▾ |
brutalist | Sharp corners; no shape morph; no rotation animation |
cyberpunk_neon | Default + glow on active state |
minimalist_mono | Single bottom-border; no fill; no morph |
T-suite
- T1 Mount: render with primary + 3 menu items → leading + divisor + trailing visible.
- T2 Primary tap: tap leading → onPrimary callback; menu NOT opened.
- T3 Menu open: tap trailing → menu visible; trailing shape morphs; chevron rotates.
- T4 Menu close: tap outside → menu closes; trailing reverts.
- T5 Keyboard: Tab to leading + Enter → onPrimary; Tab to trailing + Down → menu open.
- T6 All sizes XS-XL: render each → height correct; trailing tap-target ≥ 28dp.
- T7 All styles 4: filled/elevated/tonal/outlined render correctly.
- T8 Reduced-motion: morph + rotation instant.
- T9 A11y: leading aria-label distinct from trailing.
- N1 Click trailing accidentally → only opens menu (não executes primary action).
Cross-link
- Companion:
buttons.kmd,menus.kmd - Animation drivers:
motion.kmdR9,shape-library.kmdR2 - Color:
color-roles.kmd - Refs: M3 Split button https://m3.material.io/components/split-button/guidelines
Requirements (testable)
Requirement: Two distinct actions {#req-split-button-two-actions}
The split button SHALL expose two separately actionable regions — a leading slot that executes the primary/default action and a trailing slot that opens the options menu — and a tap on one region SHALL NOT trigger the behavior of the other. There is never a single combined action.
Scenario: Leading tap runs the primary action
- GIVEN a split button rendered with a primary action and 3 menu items
- WHEN the user taps the leading slot
- THEN the
onPrimarycallback fires exactly once - AND the options menu remains closed
Scenario: Trailing tap only opens the menu
- GIVEN a split button rendered with a primary action and 3 menu items
- WHEN the user taps the trailing slot
- THEN the options menu becomes visible
- AND the
onPrimarycallback does NOT fire
Requirement: Menu-open morph and reversal {#req-split-button-menu-morph}
When the trailing slot opens the menu the split button SHALL morph the trailing shape from square corner to pill corner and rotate the chevron 180° (▼ → ▲), and on menu close (selection, ESC, or outside tap) it SHALL reverse the morph and chevron rotation.
Scenario: Opening the menu morphs the trailing slot
- GIVEN a closed split button in the
material_expressivepreset - WHEN the user taps the trailing slot to open the menu
- THEN the trailing corner shape morphs from square to pill
- AND the chevron rotates 180° from
▼to▲ - AND
aria-expandedon the trailing slot readstrue
Scenario: Closing the menu reverts the trailing slot
- GIVEN a split button with its menu open and trailing morphed to pill
- WHEN the user taps outside the menu
- THEN the menu closes
- AND the trailing shape reverts to square with the chevron back to
▼
Requirement: Size variants {#req-split-button-sizes}
The split button SHALL render in all five sizes (XS/S/M/L/XL) with the height defined in R2 (28/32/40/48/56dp), and for every size the trailing tap target SHALL be at least 28dp so the chevron remains reliably tappable.
Scenario: Each size renders at its defined height with a valid trailing target
- GIVEN the split button size set {XS, S, M, L, XL}
- WHEN each size is rendered
- THEN the component height equals the R2 value for that size (28/32/40/48/56dp)
- AND the trailing slot tap target is ≥ 28dp
Requirement: Style variants share one style {#req-split-button-styles}
The split button SHALL render in all four styles (elevated/filled/tonal/outlined) matching the buttons.kmd base variants, and both the leading and trailing slots SHALL use the same style — mixed-style split buttons are not permitted.
Scenario: Both slots render with the same requested style
- GIVEN a split button requested with style
filled - WHEN the component renders
- THEN the leading slot uses the
filledbackground (primary) - AND the trailing slot uses the same
filledstyle - AND the two slots are separated by a 1dp divisor in
outline-variant
Scenario: All four styles render
- GIVEN the style set {elevated, filled, tonal, outlined}
- WHEN each style is rendered
- THEN each produces the R3 background/border/elevation for that style
- AND both slots share that style in every case
Requirement: Accessibility and keyboard operation {#req-split-button-a11y}
The split button SHALL expose the leading and trailing slots as separately focusable buttons with distinct accessible labels (trailing carrying aria-haspopup="menu" and a live aria-expanded), SHALL let Tab move focus leading → trailing with Space/Enter activating each, SHALL open the menu on Arrow Down over the trailing slot, and SHALL close the menu on ESC returning focus to the trailing slot.
Scenario: Leading and trailing carry distinct roles and labels
- GIVEN a split button whose primary action is "Save"
- WHEN the accessibility tree is inspected
- THEN the leading slot exposes
role="button"witharia-label="Save" - AND the trailing slot exposes
role="button",aria-haspopup="menu", andaria-label="Save options" - AND the leading
aria-labelis distinct from the trailingaria-label
Scenario: Keyboard reaches and activates both slots
- GIVEN keyboard focus is outside the split button
- WHEN the user presses Tab, then Enter, then Tab, then Arrow Down
- THEN the first Tab focuses the leading slot and Enter fires
onPrimary - AND the second Tab focuses the trailing slot and Arrow Down opens the menu
Scenario: ESC closes the menu and restores focus
- GIVEN the menu is open with focus inside it
- WHEN the user presses ESC
- THEN the menu closes
- AND focus returns to the trailing slot
Requirement: Reduced-motion suppresses animation {#req-split-button-reduced-motion}
When reduced-motion is active the split button SHALL apply the shape morph and chevron rotation instantly with no spring, and the menu SHALL appear instantly with no slide.
Scenario: Reduced-motion opens without animated transitions
- GIVEN reduced-motion is enabled
- WHEN the user opens the menu from the trailing slot
- THEN the trailing shape morph and chevron rotation complete instantly with no spring
- AND the menu appears instantly with no slide animation
References
specs/components/buttons.kmdspecs/components/menus.kmdspecs/themes/motion.kmdspecs/themes/shape-library.kmd