---
title: "Button with menu"
description: "Performs a main action with one press, and offers related actions in a menu from a second, joined button beside it."
url: "https://tanrei.chnotchy.com/components/button-with-menu/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Button with menu

> Performs a main action with one press, and offers related actions in a menu from a second, joined button beside it.

## Overview

> [!NOTE]
> **Where the split button went**
>
> This component is not called a split button. “Split button” says how it looks, a button cut in two, and so does a connected [button group](https://tanrei.chnotchy.com/components/button-group/); what sets this component apart is the menu its second half reveals. Its behaviour ids carry the `SPB` prefix of that name.
>
> | Before | Now |
> | --- | --- |
> | Split button (`split-button`) | Button with menu (`button-with-menu`) |
> | `comp.split-button.*`, `--tanrei-comp-split-button-*` | `comp.button-with-menu.*`, `--tanrei-comp-button-with-menu-*` |
> | `<tanrei-split-button>`, `TanreiSplitButton` (web class) | `<tanrei-button-with-menu>`, `TanreiButtonWithMenu` |
> | `SplitButton` (React, React Native) | `ButtonWithMenu` |
> | `TanreiSplitButton` (SwiftUI, Compose, Flutter) | `TanreiButtonWithMenu` |
>
> Material 3 calls its counterpart a split button; in the m3-like presets the button with menu is that component.

> [!WARNING]
> **The native platforms have not renamed it yet**
>
> The specification, the tokens, the web element (`<tanrei-button-with-menu>`) and the React wrapper (`ButtonWithMenu`) carry the name button with menu, and the web draws this revision. SwiftUI, Compose, Flutter and React Native still ship the previous revision under the name split button, so their code on this page uses `TanreiSplitButton` and `SplitButton`; it changes when each platform renames the component.
>
> This revision brings the following, which only the web draws so far:
>
> - The two halves are joined by a divider line or by a gap. The design system chooses once for every component, a design system can set this component apart, and `separation` (`auto`, `divider`, `gap`) sets one instance. The default system draws a divider.
> - The padding on the icon’s side of the leading half is a step tighter, and both rounded outer ends get a little more padding.
> - The trailing half keeps its shape while the menu is open; the chevron alone shows the open state.
> - `trailingWidth` (`default`, `narrow`) draws a slimmer trailing half: the chevron with a small inset on each side, and the little padding at the rounded outer end that the default width has too.

### Usage

A button with menu is two buttons joined into one shape. The leading half performs the main action. The trailing half, with the chevron, opens a [menu](https://tanrei.chnotchy.com/components/menu/) of related actions. The menu’s content is the menu’s own item kinds (actions, checkbox items, radio items and groups), passed the way each platform passes a menu’s items.

**Web**

```html
<tanrei-button-with-menu label="Save" menu-label="More save options">
  <span slot="icon">save</span>
  <tanrei-menu-item slot="menu" label="Save as…"></tanrei-menu-item>
  <tanrei-menu-item slot="menu" label="Save a copy"></tanrei-menu-item>
</tanrei-button-with-menu>

<script type="module">
  import '@tanrei/web/components/button-with-menu';
  import '@tanrei/web/components/menu';
  const split = document.querySelector('tanrei-button-with-menu');
  // Only the leading half's click reaches the element.
  split.addEventListener('click', save);
  // The trailing half asks to open or close; you set open.
  split.addEventListener('toggle', (e) => (split.open = e.detail.open));
  // close carries the menu's reason: escape, outside, tab, focus-out or item (the web has no back).
  split.addEventListener('close', () => (split.open = false));
  // An item's click stops at the button with menu: listen on the item itself.
  split.querySelector('[label="Save as…"]').addEventListener('click', saveAs);
</script>

<!-- One instance joined by a gap, with a slim trailing half. Leave separation out to follow the design system. -->
<tanrei-button-with-menu variant="tonal" separation="gap" trailing-width="narrow" label="Merge" menu-label="More merge options">
  <tanrei-menu-item slot="menu" label="Squash and merge"></tanrei-menu-item>
  <tanrei-menu-item slot="menu" label="Rebase and merge"></tanrei-menu-item>
</tanrei-button-with-menu>

<!-- Opens on hover after 400 ms, as well as on a press; its own glyph, closed and open. -->
<tanrei-button-with-menu label="Share" menu-label="More share options" hover-open hover-delay="400">
  <span slot="chevron">more_vert</span>
  <span slot="chevron-open">close</span>
  <tanrei-menu-item slot="menu" label="Copy link"></tanrei-menu-item>
  <tanrei-menu-item slot="menu" label="Email"></tanrei-menu-item>
</tanrei-button-with-menu>
```

**React**

```tsx
import { ButtonWithMenu, MenuItem } from '@tanrei/react';

// onClick is the leading half's. A MenuItem's onClick is a listener on the item element,
// so it fires inside a ButtonWithMenu; it receives the native MouseEvent.
<ButtonWithMenu
  label="Save"
  menuLabel="More save options"
  open={open}
  onClick={save}
  onToggle={(e) => setOpen(e.detail.open)}
  onClose={() => setOpen(false)}
>
  <MenuItem slot="menu" label="Save as…" onClick={saveAs} />
  <MenuItem slot="menu" label="Save a copy" onClick={saveCopy} />
</ButtonWithMenu>

// One instance joined by a gap, with a slim trailing half.
<ButtonWithMenu variant="tonal" separation="gap" trailingWidth="narrow" label="Merge" menuLabel="More merge options"
  open={mergeOpen} onClick={merge} onToggle={(e) => setMergeOpen(e.detail.open)} onClose={() => setMergeOpen(false)}>
  <MenuItem slot="menu" label="Squash and merge" onClick={squash} />
  <MenuItem slot="menu" label="Rebase and merge" onClick={rebase} />
</ButtonWithMenu>

// Opens on hover too; a null hoverDelay takes the system's delay.
<ButtonWithMenu label="Share" menuLabel="More share options" hoverOpen hoverDelay={400} open={shareOpen}
  onToggle={(e) => setShareOpen(e.detail.open)} onClose={() => setShareOpen(false)}>
  <span slot="chevron">more_vert</span>
  <span slot="chevron-open">close</span>
  <MenuItem slot="menu" label="Copy link" onClick={copyLink} />
</ButtonWithMenu>
```

**SwiftUI**

```swift
import Tanrei

// The title first, as Button. The trailing half and the menu both write the binding.
TanreiSplitButton("Save", menuLabel: "More save options", isMenuPresented: $showsSaveOptions, action: save) {
    TanreiMenuItem("Save as…", action: saveAs)
    TanreiMenuItem("Save a copy", action: saveCopy)
}

// Opens on hover too (macOS, and iPadOS with a pointer); a nil hoverDelay takes the system's delay.
// chevron: and openChevron: take a glyph builder each, for the chevron and chevron-open slots.
TanreiSplitButton("Share", menuLabel: "More share options", isMenuPresented: $showsShareOptions,
                  opensOnHover: true, hoverDelay: .milliseconds(400), action: share) {
    TanreiMenuItem("Copy link", action: copyLink)
}
```

**Compose**

```kotlin
import io.github.chnotchy.tanrei.components.TanreiMenuItem
import io.github.chnotchy.tanrei.components.TanreiSplitButton
import io.github.chnotchy.tanrei.primitives.TanreiText
import io.github.chnotchy.tanrei.tokens.TanreiSplitButtonVariant

// State hoisting, as the menu: you keep menuExpanded.
TanreiSplitButton(
    text = "Save",
    onClick = ::save,
    menuContentDescription = "More save options",
    menuExpanded = expanded,
    onMenuExpandedChange = { expanded = it },
    onMenuDismissRequest = { expanded = false }, // receives a TanreiMenuCloseReason
    variant = TanreiSplitButtonVariant.Tonal,
    icon = { TanreiText("save") },
) {
    TanreiMenuItem(text = "Save as…", onClick = ::saveAs)
    TanreiMenuItem(text = "Save a copy", onClick = ::saveCopy)
}

// Opens on hover too, for a mouse or a stylus; a null hoverDelayMillis takes the system's delay.
TanreiSplitButton(
    text = "Share",
    onClick = ::share,
    menuContentDescription = "More share options",
    menuExpanded = shareExpanded,
    onMenuExpandedChange = { shareExpanded = it }, // a press, a key or a hover
    onMenuDismissRequest = { shareExpanded = false },
    chevron = { TanreiText("more_vert") },
    openChevron = { TanreiText("close") },
    openOnHover = true,
    hoverDelayMillis = 400,
) {
    TanreiMenuItem(text = "Copy link", onClick = ::copyLink)
}
```

**Flutter**

```dart
import 'package:tanrei/tanrei.dart';

// The button with menu opens and closes the menu through the controller; onMenuToggle and onMenuClose report it.
TanreiSplitButton(
  label: 'Save',
  menuSemanticLabel: 'More save options',
  onPressed: save, // a null onPressed disables both halves
  menuController: saveOptions,
  menuChildren: <Widget>[
    TanreiMenuItem(onPressed: saveAs, child: const Text('Save as…')),
    TanreiMenuItem(onPressed: saveCopy, child: const Text('Save a copy')),
  ],
);

// Opens on hover too, never for touch; chevron and openChevron take a glyph widget each.
TanreiSplitButton(
  label: 'Share',
  menuSemanticLabel: 'More share options',
  onPressed: share,
  openOnHover: true,
  hoverDelay: const Duration(milliseconds: 400), // null takes the system's delay
  menuChildren: <Widget>[
    TanreiMenuItem(onPressed: copyLink, child: const Text('Copy link')),
  ],
);
```

**React Native**

```tsx
import { SplitButton } from '@tanrei/react-native';

// Controlled: set menuVisible from onMenuToggle, and to false from onMenuDismiss, which receives the reason.
<SplitButton
  label="Save"
  icon="save"
  menuAccessibilityLabel="More save options"
  menuVisible={open}
  onPress={save}
  onMenuToggle={setOpen}
  onMenuDismiss={() => setOpen(false)}
  menuItems={[
    { label: 'Save as…', onPress: saveAs },
    { label: 'Save a copy', onPress: saveCopy },
  ]}
/>

// Opens on hover too, for a pointer only; chevron and openChevron are glyph names.
<SplitButton
  label="Share"
  menuAccessibilityLabel="More share options"
  chevron="more_vert"
  openChevron="close"
  hoverOpen
  hoverDelay={400}
  menuVisible={shareOpen}
  onPress={share}
  onMenuToggle={setShareOpen}
  onMenuDismiss={() => setShareOpen(false)}
  menuItems={[{ label: 'Copy link', onPress: copyLink }]}
/>
```

### Joined by a divider or by a gap

The two halves are joined in one of two ways. With a **divider** they touch, the corners where they meet are square, and a line marks the seam. With a **gap** they stand a narrow gap apart, with tighter corners where they face each other. The design system chooses once for every component that joins parts (the default system draws a divider), a design system can set this component apart, and `separation` (`auto`, `divider`, `gap`) sets one instance. The halves always sit side by side: there is no vertical form.

### A narrow trailing half

By default the trailing half holds the chevron in a square of the button’s height, with a little more padding at its rounded outer end. With `trailingWidth` set to `narrow` it is a slim handle on the main action: the chevron with a small inset on each side, and the same little padding at the rounded outer end, so that the chevron looks centred against the curve (32 / 36 / 36 in the default system: the width of a narrow icon-only [Button](https://tanrei.chnotchy.com/components/button/) of the same size, and that padding). The leading half is the same at either width.

A narrow trailing half is drawn narrower than the minimum target, but its hit area is not: the target reaches outward, past the end edge. Leave room for it there, as beside a narrow icon-only Button.

### Destructive actions

Set `destructive` on a button with menu whose main action deletes or discards something a person may not get back: Delete, with “Delete all versions” in its menu. Both halves keep the emphasis of the variant and take the error color in it, as a destructive [Button](https://tanrei.chnotchy.com/components/button/#destructive-actions) of the same variant does. A filled one is filled with the error color and a tonal one with the error container; an elevated or outlined one draws its label and chevron in the error color, and an outlined one its edge and divider too. Disabled, it looks like any disabled button with menu of its variant.

`destructive` marks the two halves only, never the menu’s items. An option in the menu may delete where the main action does not, or the reverse, so give each item that deletes its own `destructive`.

## Specs

- Category: Actions
- Level: composed
- Listing: default
- Built from: Menu
- Page: https://tanrei.chnotchy.com/components/button-with-menu/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/button-with-menu.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-button-with-menu>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/button-with-menu`).
- React: `ButtonWithMenu` from `@tanrei/react`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-button-with-menu` | lagging |
| SwiftUI | `TanreiButtonWithMenu` | planned |
| Jetpack Compose | `TanreiButtonWithMenu` | planned |
| Flutter | `TanreiButtonWithMenu` | planned |
| React Native | `ButtonWithMenu` | planned |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `destructive` (prop) | `destructive` | `role` | `destructive` | `isDestructiveAction` | `destructive` |
| `label` (prop) | `label` | `title` | `text` | `label` | `label` |
| `menuLabel` (prop) | `menuLabel` | `menuLabel` | `menuContentDescription` | `menuSemanticLabel` | `menuAccessibilityLabel` |
| `open` (prop) | `open` | `isMenuPresented` | `menuExpanded` | `menuController` | `menuVisible` |
| `disabled` (prop) | `disabled` | `disabled` | `enabled` | `onPressed` | `disabled` |
| `hoverOpen` (prop) | `hoverOpen` | `opensOnHover` | `openOnHover` | `openOnHover` | `hoverOpen` |
| `hoverDelay` (prop) | `hoverDelay` | `hoverDelay` | `hoverDelayMillis` | `hoverDelay` | `hoverDelay` |
| `click` (event) | `click` | `action` | `onClick` | `onPressed` | `onPress` |
| `toggle` (event) | `toggle` | `isMenuPresented` | `onMenuExpandedChange` | `onMenuToggle` | `onMenuToggle` |
| `close` (event) | `close` | `isMenuPresented` | `onMenuDismissRequest` | `onMenuClose` | `onMenuDismiss` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `leading`: The leading half: a button that performs the main action. It holds the optional icon and the label, and carries its own hover, focus and press.
- `trailing`: The trailing half: a button that opens and closes the menu of more options. It holds the chevron and carries its own hover, focus and press, at its default width or a narrow one. With hoverOpen on, a fine pointer resting on it opens the menu too.
- `container`: Each half's shape: its fill, outline, elevation and corners. The halves share one look (the destructive one too, SPB-34) and are joined by a divider or by a narrow gap (separation): round outer ends, and under gap tighter corners where they face each other. The trailing half keeps its shape while the menu is open in the default system.
- `icon` (optional): A glyph before the label in the leading half, drawn in the label color; the padding on its side is a step tighter than without it, as Button's.
- `label`: The leading half's text and its accessible name.
- `chevron`: The trailing half's glyph. By default it is the icon set's expand_more, turned half a turn while the menu is open. The chevron slot replaces it, and the chevron-open slot gives the trailing half a different glyph while the menu is open.
- `divider` (optional): Under divider, the line on the seam where the two halves touch. Under gap there is none.
- `menu`: The Tanrei Menu the trailing half opens, anchored to the whole button with menu. It keeps its own tokens and behaviour.
- `focus-ring`: Gap-then-ring indicator shown for keyboard focus, around whichever half has it.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `variant` | `enum`: `filled`, `tonal`, `elevated`, `outlined` | `"filled"` | `variant` | Emphasis, as Button's variants of the same names. A design system may add values. |
| `destructive` | `boolean` | `false` | `destructive` | Marks a main action that deletes or discards something a person may not get back (Delete, with Delete all in the menu): both halves keep their variant's emphasis and take the error role's color in it, as a destructive Button of the same variant does (SPB-34). Use it for that kind of action alone, never for emphasis. It adds a color, not a meaning: the label still says what the action does (SPB-35). The menu's items are not marked by it: an item that deletes carries its own destructive. |
| `size` | `enum`: `small`, `medium`, `large` | `"medium"` | `size` | Visual height (32 / 40 / 56), matching Button's heights. The hit area always meets the minimum target. A design system may add values. |
| `separation` | `enum`: `auto`, `divider`, `gap` | `"auto"` | `separation` | How the two halves are joined. divider: they touch, and a line is drawn on the seam. gap: they stand a narrow gap apart, with tighter corners where they face each other. Auto takes comp.button-with-menu.container.separation, the design system's answer (divider in the default system). The halves always sit side by side: there is no vertical form. |
| `trailingWidth` | `enum`: `default`, `narrow` | `"default"` | `trailing-width` | How wide the trailing half is. default holds the chevron in a square of the height (32 / 40 / 56); narrow makes the half a slim handle on the main action: the chevron with a small inset on each side, and the padding at the rounded outer end (32 / 36 / 36), as the default width takes it too. The hit area meets the minimum target at both. |
| `label` | `string` | `""` | `label` | The leading half's visible text and accessible name ("Save"). Required. |
| `menuLabel` | `string` | `""` | `menu-label` | The trailing half's accessible name ("More save options"). Required: the chevron alone is not a name. |
| `open` | `boolean` | `false` | `open` | Whether the menu is shown. Where open is a plain value (web, Compose, React Native) the product sets it from toggle and sets it to false from close; where it is a binding or a controller (SwiftUI, Flutter) the button with menu opens and closes the menu through it (SPB-2, SPB-16). |
| `disabled` | `boolean` | `false` | `disabled` | Prevents interaction with both halves and removes them from the focus order. |
| `hoverOpen` | `boolean` | `false` | `hover-open` | Also opens the menu when a fine pointer rests on the trailing half for hoverDelay, and closes that menu when the pointer leaves the trailing half and the menu (SPB-22 to SPB-25). A press and the keyboard still open it as without. Never applies to touch. |
| `hoverDelay` | `number` | — | `hover-delay` | How long, in milliseconds, a fine pointer rests on the trailing half before the menu opens on hover. Null takes comp.button-with-menu.motion.hover-open-delay. Ignored unless hoverOpen is true. |

### Events

| Event | Description |
| --- | --- |
| `click` | The leading half was activated by pointer, touch, keyboard or assistive technology. |
| `toggle` | The trailing half asks to open or close the menu: by a press or a key on it (SPB-2), or, with hoverOpen on, by a fine pointer resting on it or leaving it and the menu (SPB-22, SPB-23). Carries the open value it asks for. |
| `close` | The menu asks to be closed; carries the menu's reason: escape, back, outside, tab, focus-out, item or anchor-hidden (in the platform's casing); anchor-hidden when the button with menu is no longer drawn while its menu is open (MNU-65). |

### Slots

| Slot | Description |
| --- | --- |
| `icon` | The leading half's icon (a glyph of the system's icon set). |
| `chevron` | The trailing half's glyph in place of expand_more (one glyph of the system's icon set). Alone, it is turned half a turn while the menu is open, as the default is; with chevron-open it is the closed form only (SPB-21). |
| `chevron-open` | The trailing half's glyph while the menu is open (one glyph of the system's icon set). When it is filled, the trailing half cross-fades between the closed glyph and this one instead of turning (SPB-21). |
| `menu` | The menu's content: the menu's own item kinds (item, checkbox-item, radio-item and group, as the menu spec defines them), which the button with menu presents in a Tanrei Menu. Fixtures cannot fill it (SPB-15). |

### States

`enabled`, `disabled`, `hovered`, `focused`, `pressed`

### Accessibility

- Role: `button`
- Labeling: The leading half is named by label, the trailing half by menuLabel, which should say what more the menu offers ("More save options"). The trailing half exposes that it opens a menu and whether it is open (aria-haspopup and aria-expanded on the web), however it opened. The icon and the chevron, whether the default or a slot's glyph, are decorative.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Moves focus to the leading half, then to the trailing half. |
| Enter or Space on the leading half | Performs the main action (click). |
| Enter, Space or Down Arrow on the trailing half | Opens the menu (Enter and Space also close it); focus moves to the first enabled item. On a menu that opened on hover, keeps it open and moves focus to its first enabled item. |
| Escape (in the menu) | Closes the menu and returns focus to the trailing half. |
| Escape (a menu that opened on hover) | Closes the menu wherever focus is, and leaves focus where it was. |

- Two buttons, two Tab stops: the trailing half is a menu button in the WAI-ARIA sense, and the menu is the Tanrei Menu with its own keyboard (MNU-7).
- The menu's items are the menu's own item kinds; their events (an item's click, a checkbox item's change, a group's change) are the menu's, and reach the product as they do from a menu anywhere.
- Opening on hover is an addition for fine pointers, never a replacement: every menu that hover opens can also be opened by a press and by the keyboard, and keyboard and screen reader use is unchanged. It meets WCAG 1.4.13: the pointer can move onto the menu (the close delay), Escape dismisses it without moving the pointer, and it stays until the pointer leaves or the person dismisses it. It does not take focus, so a person typing elsewhere is not interrupted.
- Text must remain readable at 200% text size: both halves grow in height together and the label never truncates.
- How a state looks is a value decision of the active design system; components read the state's tokens and never compute the effect themselves.
- The divider is decoration: it is not exposed and takes no press. The two halves are two buttons whether a line or a gap is between them.
- A narrow trailing half is drawn narrower than the minimum target; its hit area is not (SPB-6). Its target reaches outward past the end edge, so leave room for it there.
- In the default system the outline of the outlined variant, and the divider of the outlined, tonal and elevated variants, are the hairline (the divider's color), far below 3:1 against their ground. That is within WCAG 1.4.11: the leading half is identified by its label, the trailing half by its chevron, and each half answers hover, focus and press on its own shape. The divider of the filled variant keeps the functional outline role, because a hairline cannot be seen on that fill.
- destructive adds a color, never a meaning (WCAG 1.4.1, use of color): the error color tells people who see it that the main action deletes or discards something, and nothing tells a screen reader user so but the label. So the label names the action and what it acts on ("Delete project", not "OK"). Disabled, a destructive button with menu looks like any disabled one of its variant: a button that cannot be pressed does nothing.

### Behaviour

Requirements in EARS form, each with its stable id.

- **SPB-1** WHEN the user activates the leading half by pointer, touch, Enter or Space THE button with menu SHALL emit click exactly once, and SHALL NOT open or close the menu.
- **SPB-2** WHEN the user activates the trailing half by pointer, touch, Enter or Space THE button with menu SHALL emit toggle exactly once with the opposite of open, and WHEN the user presses Down Arrow on the trailing half while open is false it SHALL emit toggle with true; the menu SHALL then open or close through open: through its binding or controller where open is one (SwiftUI, Flutter), and by the product setting open where open is a plain value (web, Compose, React Native). A menu that is open from hover is the exception: activating the trailing half then keeps it open and emits no toggle (SPB-24).
- **SPB-3** WHILE disabled is true THE button with menu SHALL NOT emit click, toggle or close, SHALL NOT make either half focusable, SHALL expose the disabled state on both halves and SHALL present no menu, whatever open says; it SHALL NOT open the menu on hover, whatever hoverOpen says.
- **SPB-4** WHILE a half is hovered, keyboard-focused or pressed THE button with menu SHALL render that half's container, label, icon or chevron, outline, elevation and opacity from the component tokens of its variant in that half's highest-precedence active state, with precedence pressed over focused over hovered, and SHALL keep the other half at its own state (neither half is hovered or pressed while the pointer or the press is on the other); focus gained by pointer or touch SHALL NOT select the focused state.
- **SPB-5** WHEN a half receives keyboard focus THE button with menu SHALL show the focus ring around that half's own shape, without animation; focus gained by pointer or touch SHALL NOT show it.
- **SPB-6** WHERE a half is smaller than the minimum target THE button with menu SHALL extend that half's hit area on both axes to sys.target.touch for touch input and sys.target.pointer for fine pointers without changing its visual size — above and below, and outward past the button with menu's start edge for the leading half and past its end edge for the trailing half — and toward the other half no further than the middle of the gap between them, or the seam where they touch, so that the two never overlap; a narrow trailing half SHALL reach its target the same way and SHALL NOT be drawn wider for it; a platform that cannot tell the input type SHALL use sys.target.touch.
- **SPB-7** WHILE disabled is true THE button with menu SHALL render both halves' containers, label, icon, chevron, outline, elevation and opacity from the disabled component tokens of its variant. A disabled button with menu SHALL draw its closed form (the trailing half's closed shape and the closed chevron: the chevron slot's glyph, or expand_more when that slot is empty, unturned), whatever open says.
- **SPB-8** WHEN variant or size names a value that the active design system does not have THE button with menu SHALL render with the default value's tokens, and WHEN separation or trailingWidth names a value it does not define THE button with menu SHALL render with that prop's default (auto, default); in debug builds it SHALL report the unknown value.
- **SPB-9** WHEN a half of the button with menu is pressed by pointer, touch, Enter or Space THE half SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner.
- **SPB-10** WHILE pressed THE half SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; label, icon, chevron, outline, elevation, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.button-with-menu.motion.feedback-duration with comp.button-with-menu.motion.state-easing, fades over comp.button-with-menu.motion.state-duration with comp.button-with-menu.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
- **SPB-11** THE half SHALL ease its container corners by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0) and show its keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
- **SPB-12** THE button with menu SHALL draw its two halves in one row, joined as its separation names (SPB-30): the leading half at least the height of its size (comp.button-with-menu.container.height.medium for medium, and so on) and as wide as its content, holding its icon, when it has one, and then its label, comp.button-with-menu.container.gap.medium apart, padded at the start by comp.button-with-menu.leading.padding-inline-start-with-icon.medium when it has an icon and by comp.button-with-menu.leading.padding-inline-start.medium when it has none, at the end by comp.button-with-menu.leading.padding-inline-end.medium, and above and below by at least comp.button-with-menu.container.padding-block.medium; and the trailing half as tall as the leading half, holding the chevron centred between comp.button-with-menu.trailing.padding-inline.medium on each side WHILE trailingWidth is default and between comp.button-with-menu.trailing.padding-inline-narrow.medium on each side WHILE trailingWidth is narrow (and so on for each size). Each half SHALL then take the outer padding of SPB-32. THE button with menu SHALL NOT stack its halves: it has no vertical form.
- **SPB-13** THE button with menu SHALL round the leading half's start corners and the trailing half's end corners by comp.button-with-menu.container.shape, capped at half that half's height (so that the default system's sys.shape.corner.full draws round ends) and eased by sys.shape.corner-smoothing. WHILE its separation is gap it SHALL round the corners where the halves face each other by comp.button-with-menu.container.inner-shape and, WHILE open is true, the trailing half's by comp.button-with-menu.container.inner-shape-open, each capped and eased the same way; WHILE its separation is divider those corners SHALL be square, open or not. WHILE open is true the chevron SHALL show its open form (SPB-21). WHEN open changes THE button with menu SHALL change the chevron, and the trailing half's inner corners where the two tokens differ, over comp.button-with-menu.motion.open-duration with comp.button-with-menu.motion.open-easing, and at once where motion is reduced. THE button with menu SHALL NOT change the shape of a half for any other reason: where comp.button-with-menu.container.inner-shape-open has the value of comp.button-with-menu.container.inner-shape, as in the default system, the trailing half keeps its shape while the menu is open and the chevron alone shows it. Each half SHALL clip its fill, state layer and ripple to its own shape, and draw its outline and its elevation's ring as SPB-26 says.
- **SPB-14** THE button with menu SHALL set the label in the text style of its size (comp.button-with-menu.label.typescale.medium, and so on) and the label color of its variant and the leading half's state (comp.button-with-menu.filled.label.color for filled at rest, and so on), SHALL NOT truncate it (it stays on one line while it fits, wraps when the layout gives it less width, and the halves grow in height with it), and SHALL draw the icon and the chevron (expand_more, or a chevron slot's glyph, SPB-21) at the icon size of its size (comp.button-with-menu.icon.size.medium, and so on) through the icon helper, each in the label color of its own half's state, with the family and fill of an unselected icon, sys.icon.weight, and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max.
- **SPB-15** WHEN open becomes true THE button with menu SHALL present the items of its menu slot as a Tanrei Menu, which then behaves as MNU-1 to MNU-29 say, anchored to the whole button with menu with placement bottom-end (below it with the end edges aligned, flipping as MNU-2 says), with the trailing half as the element focus returns to when the menu closes: an operated overlay that is not modal, as the menu is; a menu that opens from hover SHALL NOT take focus (SPB-22). WHEN open becomes false THE button with menu SHALL remove the menu.
- **SPB-16** WHEN the menu asks to be closed (MNU-5, MNU-6) THE button with menu SHALL emit close once with the menu's reason and SHALL be closed: through its binding or controller where open is one (SwiftUI, Flutter), and by the product setting open to false where open is a plain value (web, Compose, React Native); WHEN an action item of the menu is activated THE button with menu SHALL let the item emit its click first and then emit close with the reason item. A press on the trailing half while the menu is open is not a press outside: it SHALL emit toggle with false (SPB-2), or keep a menu that is open from hover open (SPB-24), and emit no close.
- **SPB-17** THE button with menu SHALL expose two buttons in reading order, each its own Tab stop: the leading half named by label, and the trailing half named by menuLabel, exposing that it opens a menu and whether the menu is open where the platform has those properties, however it opened; the icon and the chevron (expand_more or a chevron slot's glyph) SHALL be hidden, and the button with menu SHALL expose no element of its own around them.
- **SPB-18** THE button with menu SHALL cast the blurred layers of each half's elevation, of its variant and that half's state (comp.button-with-menu.elevated.container.elevation for elevated at rest, and so on), outside that half without clipping them, and draw the elevation's ring inside the half (SPB-26); WHEN the elevation or the colors of a half change between states THE button with menu SHALL animate the change over comp.button-with-menu.motion.state-duration with comp.button-with-menu.motion.state-easing, changing them at once where motion is reduced.
- **SPB-19** WHEN the layout direction is right-to-left THE button with menu SHALL put the trailing half on the left, mirror the order of the leading half's icon and label and apply its paddings by logical start and end, SHALL present its menu with the end edges aligned at the left (the menu's left edge on the button with menu's left edge, below the trailing half: SPB-15 and MNU-2), SHALL NOT mirror the chevron expand_more or change the way it turns, SHALL leave a glyph of the icon slot or of a chevron slot to the icon helper (SPB-28), and SHALL grow a ripple from the point of contact as in left-to-right.
- **SPB-20** WHILE label is not empty THE button with menu SHALL expose it as the leading half's accessible name, over a name the product gives it on the element (aria-label or aria-labelledby). WHILE label is empty and the product gives the button with menu a name of its own on the element — aria-label, or aria-labelledby naming an element — THE button with menu SHALL expose that name as the leading half's accessible name. WHILE label is empty and the product gives it none, or menuLabel is empty, THE button with menu SHALL still render, and in debug builds report the half that has no accessible name; WHILE the menu slot holds no item THE button with menu SHALL still emit toggle and draw the trailing half's open form, SHALL present no menu, and in debug builds report the empty menu.
- **SPB-21** WHILE neither the chevron slot nor the chevron-open slot holds a glyph THE button with menu SHALL draw the icon set's expand_more as the chevron, turned half a turn while open is true; WHILE only the chevron slot holds a glyph THE button with menu SHALL draw that glyph in its place, turned the same way; WHILE the chevron-open slot holds a glyph THE button with menu SHALL draw it unturned while open is true and, while open is false, the chevron slot's glyph (or expand_more when that slot is empty) unturned, and WHEN open changes SHALL cross-fade from one to the other instead of turning. Each slot SHALL hold at most one glyph: the button with menu SHALL draw the first and in debug builds report the rest.
- **SPB-22** WHERE hoverOpen is true, WHEN a fine pointer (a mouse, a trackpad, or a pen that hovers) enters the trailing half while open is false and stays on it for hoverDelay milliseconds, or for comp.button-with-menu.motion.hover-open-delay when hoverDelay is null, THE button with menu SHALL emit toggle with true once, and WHEN open then becomes true the menu SHALL be open from hover: THE button with menu SHALL present it without moving focus into it (in place of MNU-1's move to the first item), focus staying where it was, and a pointer on an item SHALL render the item hovered without moving focus to it (in place of MNU-8). The pointer leaving the trailing half before the delay has passed, a press on either half, or open becoming true by other means SHALL cancel the wait. Touch, and pointer events a platform synthesises from touch, SHALL never open the menu on hover. WHERE hoverDelay is negative or not a number THE button with menu SHALL wait for comp.button-with-menu.motion.hover-open-delay, and in debug builds report the value.
- **SPB-23** WHILE the menu is open from hover, WHEN the pointer has been off both the trailing half and the menu's surface for comp.button-with-menu.motion.hover-close-delay THE button with menu SHALL emit toggle with false once and SHALL NOT move focus; a pointer that comes back onto either within that time SHALL keep the menu open, and a press outside both SHALL close it as MNU-5 says. WHILE the menu is open for any other reason (a press, a key, or the product setting open) THE button with menu SHALL NOT close it when the pointer leaves.
- **SPB-24** WHEN the user activates the trailing half by pointer, touch, Enter, Space or Down Arrow while the menu is open from hover THE button with menu SHALL NOT emit toggle, SHALL keep the menu open and SHALL move focus to its first enabled item as MNU-1 says; from then on the menu SHALL be open as if that activation had opened it: the pointer leaving no longer closes it (SPB-23), MNU-8 applies again, and the next activation of the trailing half emits toggle with false (SPB-2).
- **SPB-25** WHILE the menu is open from hover, WHEN the user presses Escape or the platform's back, wherever focus is, THE button with menu SHALL emit close once with the reason escape or back, SHALL be closed as SPB-16 says, SHALL NOT move focus, and SHALL keep that key from reaching anything else, so that what hover showed can be dismissed without moving the pointer (WCAG 1.4.13); a later Escape reaches the page as usual.
- **SPB-26** THE button with menu SHALL draw each half's outline (comp.button-with-menu.outline.width.medium wide at the medium size, and so on, in the outline color of its variant and that half's state) and the ring of its elevation (the layer with no offset, no blur and a positive spread, which the default system gives every level above sys.elevation.level0 and which a platform otherwise draws as a border) inside that half's own edge, the outline over the ring. WHILE its separation is gap neither SHALL reach into the comp.button-with-menu.container.inner-gap between the halves, so that the two halves' edges never meet, however narrow that gap is; WHILE its separation is divider each half SHALL draw neither along the edge where the halves touch, so that the outline and the ring run once around the joined shape and the divider (SPB-31) is the only line on the seam. Only the elevation's blurred layers SHALL fall outside a half (SPB-18).
- **SPB-27** THE button with menu SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the button with menu is shown THE button with menu SHALL draw itself for the new direction at once, without a motion of its own, keeping its open state and its focus, an open menu placed again by the new direction.
- **SPB-28** WHERE the layout direction is right-to-left THE button with menu SHALL draw the glyph of its icon slot and a glyph of its chevron or chevron-open slot mirrored left to right exactly when the system's icon set marks that glyph's name as mirrored or the product forces mirroring for that use, and unmirrored when the product forbids it: the icon helper mirrors the glyph inside its own box, about the box's vertical centre line, changing neither its place, size, color and fill nor the order SPB-19 gives. THE button with menu SHALL NOT mirror a glyph itself, and SHALL NOT mirror content the product gives as an image or a view of its own rather than by a glyph's name. The chevron it draws itself (expand_more) is not marked and is never mirrored, and a mirrored chevron-slot glyph is turned while open as an unmirrored one is (SPB-21).
- **SPB-29** THE button with menu SHALL lay out its label as text of its own direction, isolated from the text around it: each takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right word, a number with its sign or unit, a telephone number and an address keep their own order in a right-to-left layout and reorder nothing beside them; THE button with menu SHALL still place the label and align its lines by the layout direction, not by the text's own.
- **SPB-30** THE button with menu SHALL join its two halves as its separation names: WHILE separation is auto, as comp.button-with-menu.container.separation names, which follows sys.behavior.separation unless a design system sets it apart; WHILE separation is divider or gap, as that value names, whatever the tokens say. Under gap the halves SHALL stand comp.button-with-menu.container.inner-gap apart with nothing drawn between them and the inner corners of SPB-13; under divider the halves SHALL touch, with square inner corners and the divider of SPB-31 on the seam. WHEN separation, or the token it follows, changes THE button with menu SHALL draw the other form at once, without animation, keeping its open state and its focus. A platform that cannot draw a choice SHALL fall back to the first value, divider, and state that in its README.
- **SPB-31** WHILE its separation is divider THE button with menu SHALL draw on the seam between its halves one line comp.button-with-menu.divider.thickness thick, in the divider color of its variant (comp.button-with-menu.filled.divider.color for filled at rest, and so on), centred on the seam, over the halves' fills and state layers, from comp.button-with-menu.divider.inset below the top of the seam to the same distance above its bottom. The line SHALL take no space of its own, SHALL NOT take a press (a press on it goes to the half under the point), SHALL NOT be exposed to assistive technology, and SHALL keep its resting color while either half is hovered, focused or pressed; WHILE disabled is true it SHALL take the disabled divider color of its variant. A half's state layer, ripple and focus ring SHALL still follow that half's own shape.
- **SPB-32** THE button with menu SHALL add comp.button-with-menu.container.outer-inset to the leading half's start padding, under either separation and at either trailing width, and to the trailing half's end padding, under either separation and at either trailing width, so that content beside a rounded outer end looks as far from it as from the tighter corner on its other side: the leading half SHALL be wider by that amount with its icon and label moved inward by it, and the trailing half SHALL be wider by that amount with its chevron centred in what is left, half of it nearer the leading half. WHILE trailingWidth is narrow THE trailing half SHALL be as wide as the chevron's box, comp.button-with-menu.trailing.padding-inline-narrow.medium on each side of it (of its own size) and the outer padding, with the chevron centred in what is left: a slim half beside a full end needs the compensation as much as a wide one, its chevron being nearer the curve, so it is one outer padding wider than a narrow icon-only Button of its size (BTN-12) where the two components' tokens agree, as in the default system. In a right-to-left layout the start is the right, as SPB-19 says.
- **SPB-33** WHERE the component that places the button with menu keeps one Tab stop among its items and makes each half a stop of its own (a toolbar, TBR-25), through the internal channel a Button reads from the component that places it (BTN-10), THE button with menu SHALL let that component say which half, if either, is its Tab stop and move focus to either half, SHALL take the other half out of the Tab order while it does, and SHALL keep each half's own name, role, state, keys and activation. WHILE no component does so THE button with menu SHALL keep both halves in the Tab order, as SPB-17 says. It adds nothing to the public surface: no prop, attribute or styling property says which half is a stop.
- **SPB-34** WHILE destructive is true THE button with menu SHALL render both halves' containers, label, icon, chevron and outline, and the divider, from the destructive parts of its variant (comp.button-with-menu.filled.destructive.container.color, and so on for each variant), each half in its own state from the destructive parts of that state, and its elevation, shape and opacity as it would without destructive; WHILE destructive is false it SHALL render from the variant's own parts. In every shipped system the destructive parts are those of a destructive Button of the same variant (BTN-40) — filled: the error color with the on-error color on it; tonal: the error container with the on-error-container color on it; elevated and outlined: the variant's own container with the label, the icon and the chevron in the error color, and an outlined half's edge and divider in it too — and disabled they are those of the variant's plain disabled look, so that a disabled destructive button with menu looks like any disabled button with menu of its variant. THE button with menu SHALL NOT pass destructive to the items of its menu: an item is drawn destructive by its own destructive alone (MNU-18), since an option of the menu may delete where the main action does not, and the reverse.
- **SPB-35** THE button with menu SHALL NOT make color the only sign that its main action is destructive: WHILE destructive is true it SHALL keep both halves' roles, accessible names, states and keyboard behaviour exactly as they are without it, and SHALL expose nothing more to assistive technology on the web, where no role or state says it; WHERE the platform has a destructive role of its own (SwiftUI's ButtonRole.destructive) THE button with menu SHALL give it to the leading half, whose press performs the action, and not to the trailing half, which only opens the menu. Its label says what it destroys.
- **SPB-36** WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE button with menu SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on the element that carries its role (the leading half carries them) — an id reference by the elements it names in the host's tree or a tree around it, an error message as part of the description while the button with menu is invalid — except those the button with menu owns (aria-haspopup, aria-expanded, aria-controls, aria-disabled), which it SHALL move and not expose. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE button with menu SHALL take it off that element. WHEN the product sets an aria- attribute the button with menu owns THE button with menu SHALL report it in debug builds.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.button-with-menu.…` → `--tanrei-comp-button-with-menu-…`).

#### `tanrei.comp.button-with-menu`

| Token | Type | Value |
| --- | --- | --- |
| `comp.button-with-menu.container.separation` | `number` | `{tanrei.sys.behavior.separation}` |
| `comp.button-with-menu.container.shape` | `dimension` | `{tanrei.sys.shape.corner.full}` |
| `comp.button-with-menu.container.inner-shape` | `dimension` | `{tanrei.sys.shape.corner.small}` |
| `comp.button-with-menu.container.inner-shape-open` | `dimension` | `{tanrei.comp.button-with-menu.container.inner-shape}` |
| `comp.button-with-menu.container.inner-gap` | `dimension` | `{tanrei.sys.space.space25}` |
| `comp.button-with-menu.container.outer-inset` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.button-with-menu.container.height.small` | `dimension` | `32px` |
| `comp.button-with-menu.container.height.medium` | `dimension` | `40px` |
| `comp.button-with-menu.container.height.large` | `dimension` | `56px` |
| `comp.button-with-menu.container.padding-block.small` | `dimension` | `6px` |
| `comp.button-with-menu.container.padding-block.medium` | `dimension` | `6px` |
| `comp.button-with-menu.container.padding-block.large` | `dimension` | `6px` |
| `comp.button-with-menu.container.gap.small` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.button-with-menu.container.gap.medium` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.button-with-menu.container.gap.large` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.button-with-menu.divider.thickness` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.button-with-menu.divider.inset` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.button-with-menu.leading.padding-inline-start.small` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.leading.padding-inline-start.medium` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.leading.padding-inline-start.large` | `dimension` | `{tanrei.sys.space.space300}` |
| `comp.button-with-menu.leading.padding-inline-start-with-icon.small` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.button-with-menu.leading.padding-inline-start-with-icon.medium` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.button-with-menu.leading.padding-inline-start-with-icon.large` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.leading.padding-inline-end.small` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.leading.padding-inline-end.medium` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.leading.padding-inline-end.large` | `dimension` | `{tanrei.sys.space.space300}` |
| `comp.button-with-menu.trailing.padding-inline.small` | `dimension` | `{tanrei.sys.space.space75}` |
| `comp.button-with-menu.trailing.padding-inline.medium` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.button-with-menu.trailing.padding-inline.large` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.button-with-menu.trailing.padding-inline-narrow.small` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.button-with-menu.trailing.padding-inline-narrow.medium` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.button-with-menu.trailing.padding-inline-narrow.large` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.button-with-menu.label.typescale.small` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.button-with-menu.label.typescale.medium` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.button-with-menu.label.typescale.large` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.button-with-menu.icon.size.small` | `dimension` | `{tanrei.sys.icon.small}` |
| `comp.button-with-menu.icon.size.medium` | `dimension` | `{tanrei.sys.icon.medium}` |
| `comp.button-with-menu.icon.size.large` | `dimension` | `{tanrei.sys.icon.medium}` |
| `comp.button-with-menu.outline.width.small` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.button-with-menu.outline.width.medium` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.button-with-menu.outline.width.large` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.button-with-menu.filled.container.color` | `color` | `{tanrei.sys.color.primary}` |
| `comp.button-with-menu.filled.container.elevation` | `shadow` | `{tanrei.sys.elevation.level0}` |
| `comp.button-with-menu.filled.label.color` | `color` | `{tanrei.sys.color.on-primary}` |
| `comp.button-with-menu.filled.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.button-with-menu.filled.divider.color` | `color` | `{tanrei.sys.color.outline}` |
| `comp.button-with-menu.filled.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.filled.label.color}` |
| `comp.button-with-menu.filled.destructive.container.color` | `color` | `{tanrei.comp.button.filled.destructive.container.color}` |
| `comp.button-with-menu.filled.destructive.label.color` | `color` | `{tanrei.comp.button.filled.destructive.label.color}` |
| `comp.button-with-menu.filled.destructive.outline.color` | `color` | `{tanrei.comp.button.filled.destructive.outline.color}` |
| `comp.button-with-menu.filled.destructive.divider.color` | `color` | `{tanrei.comp.button-with-menu.filled.divider.color}` |
| `comp.button-with-menu.filled.destructive.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.filled.destructive.label.color}` |
| `comp.button-with-menu.tonal.container.color` | `color` | `{tanrei.sys.color.secondary-container}` |
| `comp.button-with-menu.tonal.container.elevation` | `shadow` | `{tanrei.sys.elevation.level0}` |
| `comp.button-with-menu.tonal.label.color` | `color` | `{tanrei.sys.color.on-secondary-container}` |
| `comp.button-with-menu.tonal.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.button-with-menu.tonal.divider.color` | `color` | `{tanrei.sys.color.outline-variant}` |
| `comp.button-with-menu.tonal.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.tonal.label.color}` |
| `comp.button-with-menu.tonal.destructive.container.color` | `color` | `{tanrei.comp.button.tonal.destructive.container.color}` |
| `comp.button-with-menu.tonal.destructive.label.color` | `color` | `{tanrei.comp.button.tonal.destructive.label.color}` |
| `comp.button-with-menu.tonal.destructive.outline.color` | `color` | `{tanrei.comp.button.tonal.destructive.outline.color}` |
| `comp.button-with-menu.tonal.destructive.divider.color` | `color` | `{tanrei.comp.button-with-menu.tonal.divider.color}` |
| `comp.button-with-menu.tonal.destructive.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.tonal.destructive.label.color}` |
| `comp.button-with-menu.elevated.container.color` | `color` | `{tanrei.sys.color.surface-container-lowest}` |
| `comp.button-with-menu.elevated.container.elevation` | `shadow` | `{tanrei.sys.elevation.level2}` |
| `comp.button-with-menu.elevated.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.button-with-menu.elevated.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.button-with-menu.elevated.divider.color` | `color` | `{tanrei.sys.color.outline-variant}` |
| `comp.button-with-menu.elevated.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.elevated.label.color}` |
| `comp.button-with-menu.elevated.destructive.container.color` | `color` | `{tanrei.comp.button.elevated.destructive.container.color}` |
| `comp.button-with-menu.elevated.destructive.label.color` | `color` | `{tanrei.comp.button.elevated.destructive.label.color}` |
| `comp.button-with-menu.elevated.destructive.outline.color` | `color` | `{tanrei.comp.button.elevated.destructive.outline.color}` |
| `comp.button-with-menu.elevated.destructive.divider.color` | `color` | `{tanrei.comp.button-with-menu.elevated.divider.color}` |
| `comp.button-with-menu.elevated.destructive.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.elevated.destructive.label.color}` |
| `comp.button-with-menu.outlined.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.button-with-menu.outlined.container.elevation` | `shadow` | `{tanrei.sys.elevation.level0}` |
| `comp.button-with-menu.outlined.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.button-with-menu.outlined.outline.color` | `color` | `{tanrei.sys.color.outline-variant}` |
| `comp.button-with-menu.outlined.divider.color` | `color` | `{tanrei.comp.button-with-menu.outlined.outline.color}` |
| `comp.button-with-menu.outlined.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.outlined.label.color}` |
| `comp.button-with-menu.outlined.destructive.container.color` | `color` | `{tanrei.comp.button.outlined.destructive.container.color}` |
| `comp.button-with-menu.outlined.destructive.label.color` | `color` | `{tanrei.comp.button.outlined.destructive.label.color}` |
| `comp.button-with-menu.outlined.destructive.outline.color` | `color` | `{tanrei.comp.button.outlined.destructive.outline.color}` |
| `comp.button-with-menu.outlined.destructive.divider.color` | `color` | `{tanrei.comp.button-with-menu.outlined.destructive.outline.color}` |
| `comp.button-with-menu.outlined.destructive.state-layer.color` | `color` | `{tanrei.comp.button-with-menu.outlined.destructive.label.color}` |
| `comp.button-with-menu.motion.state-duration` | `duration` | `{tanrei.sys.motion.role.state.default}` |
| `comp.button-with-menu.motion.state-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.state}` |
| `comp.button-with-menu.motion.feedback-duration` | `duration` | `{tanrei.sys.motion.role.feedback}` |
| `comp.button-with-menu.motion.open-duration` | `duration` | `{tanrei.sys.motion.role.appear.anchored.enter}` |
| `comp.button-with-menu.motion.open-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.spatial}` |
| `comp.button-with-menu.motion.hover-open-delay` | `duration` | `{tanrei.sys.motion.duration.medium2}` |
| `comp.button-with-menu.motion.hover-close-delay` | `duration` | `{tanrei.sys.motion.duration.medium2}` |

Per-state tokens, valued by the design system's pattern rules (not written by hand):

- `comp.button-with-menu.<variant>.<state>.<property>` for the variants `filled`, `tonal`, `elevated`, `outlined`, the states `hovered`, `focused`, `pressed`, `disabled` and the properties `container.color`, `container.elevation`, `label.color`, `outline.color`, `divider.color`, `state-layer.color`, `destructive.container.color`, `destructive.label.color`, `destructive.outline.color`, `destructive.divider.color`, `destructive.state-layer.color`.
- `comp.button-with-menu.<state>.<property>` for the states `hovered`, `focused`, `pressed`, `disabled` and the properties `opacity`, `container.shape`.

## Guidelines

### When to use

Use a button with menu for an action that has a usual form and a few variations: Save and “Save as…”, Merge and “Squash and merge”, Send and “Schedule send”. The usual form takes one press, and the variations stay one press further away.

If none of the options is clearly the usual one, use a button or an icon button that opens a menu. If there are no variations, use a plain [button](https://tanrei.chnotchy.com/components/button/). For a set of separate actions side by side, use a [button group](https://tanrei.chnotchy.com/components/button-group/).

### Choosing a variant

The variants carry the same emphasis as Button’s, and the same budget applies: one strong action per view.

| Variant | Emphasis | Use for |
| --- | --- | --- |
| Filled (default) | Highest | The main action of a view. |
| Tonal | High | An important action that should not compete with a filled button. |
| Elevated | Medium | A button with menu over a busy or patterned background. |
| Outlined | Medium | A secondary action beside a filled button. |

There is no text variant. Without a fill or a border, nothing would show where the two halves meet, and the split would disappear.

The sizes have Button’s heights, so a button with menu can share a row with buttons of the same size. The outer ends are fully round in the default system. Where the halves meet depends on the separation: under a divider they touch at square corners with a line on the seam, and neither half draws its outline along it, so the outline of an outlined button with menu is one line all round; under a gap each half draws its outline, and the ring an elevated surface has, inside its own edge, so the two never touch.

### The chevron

The trailing half draws `expand_more` from the icon set and turns it half a turn while the menu is open. In the default system the trailing half keeps its shape while open, so the chevron alone shows the open state. A design system can give the open half its own inner corners: the m3-like presets round it into a circle while open.

Two slots change the glyph. Each takes one glyph of the icon set:

| Slots you fill | Closed | Open |
| --- | --- | --- |
| Neither | `expand_more` | `expand_more`, turned |
| `chevron` only | Your glyph | Your glyph, turned |
| `chevron-open` (with or without `chevron`) | The `chevron` glyph, or `expand_more` | The `chevron-open` glyph; the two cross-fade instead of turning |

A glyph without a direction, such as `more_vert`, should not turn: give it with a `chevron-open` glyph, or put the same glyph in both slots.

### Opening on hover

With `hoverOpen`, the menu also opens when a mouse, a trackpad or a hovering pen rests on the trailing half for `hoverDelay` milliseconds. Leave `hoverDelay` unset to take the system’s delay, 300 ms in the default system. A press and the keyboard still open the menu as before, and touch never opens it on hover.

- A menu opened by hover does not take focus: focus stays where the person was, for example in a text field. Items still show hover, and a click on one still activates it.
- It closes once the pointer has been off both the trailing half and the menu for a short grace, so the pointer can cross onto the menu or slip off an edge and come back. A menu opened any other way never closes when the pointer leaves.
- A press or a key on the trailing half while a hover-opened menu shows keeps it open and moves focus into it. From then on it is a menu opened by a press: the next press closes it.
- Escape closes a hover-opened menu wherever focus is.
- Hover asks through the same toggle event as a press, so the code that sets `open` needs no change.

Use hover opening where a pointer is common and a menu that appears on its own is welcome, such as a toolbar in a desktop editor. It never replaces the press.

### The menu

- The menu is a Tanrei Menu, with everything the menu gives: focus moves into it, the arrow keys and typeahead move through it, and it flips and shifts to stay on screen. It opens below the whole button with menu, aligned with its end edge.
- While the menu is open, the chevron shows its open form (see [The chevron](https://tanrei.chnotchy.com/components/button-with-menu/#the-chevron)).
- When the menu closes and focus was inside it, focus returns to the trailing half, the button that opened it.
- A press on the trailing half while the menu is open is a toggle, not a press outside: it asks to close with `toggle` and sends no `close`. When an action item closes the menu, the item’s click comes first, then `close` with the reason `item`.
- Who closes the menu follows the platform. On the web, Compose and React Native you set `open` (`menuExpanded`, `menuVisible`) from the toggle event and back to false from the close event. On SwiftUI and Flutter the button with menu opens and closes the menu itself through its binding or controller.

### Do

- Label the leading half with the usual action, as a verb: “Save”, “Merge”, “Send”.
- Give the trailing half a label that says what the menu offers: “More save options”, not “More” or “Menu”.
- Fill the menu with variations of the main action. The main action itself does not need to repeat there.
- Keep the menu short. A few items suit it; a long list suggests the options are not variations of one action.
- Leave `separation` at `auto` unless one instance must differ, so that every joined control in the product is told apart the same way.
- Use the narrow trailing half where the menu is secondary and width is tight, such as a table row or a dense header.

### Don’t

- Don’t put unrelated actions in the menu, such as Settings or Help. Use an icon button with its own menu for those.
- Don’t use a button with menu whose menu has a single item. Show both actions as buttons.
- Don’t try to disable one half. `disabled` applies to both. If the variations are unavailable, disable those menu items.
- Don’t use a button with menu where the trailing half does something other than open its menu. Its semantics always say that it opens a menu.

## Accessibility

### What the component handles

- The button with menu is two buttons in reading order, each its own Tab stop. The leading half is named by its label. The trailing half is named by its menu label and exposes that it opens a menu and whether the menu is open, where the platform has those properties. No element wraps the two, so a screen reader hears two buttons and nothing more.
- The icon and the chevron, including a glyph in the `chevron` or `chevron-open` slot, are hidden from assistive technology. The trailing half exposes whether the menu is open however it opened, by a press, a key or hover.
- Enter and Space on the leading half perform the main action. Enter, Space or Down Arrow on the trailing half open the menu and move focus to its first enabled item. The menu then works as it does anywhere, and Escape closes it and returns focus to the trailing half.
- A menu opened by hover leaves focus where it was, and Escape closes it wherever focus is, without the pointer moving, so what hover showed can always be dismissed (WCAG 1.4.13). That Escape goes no further; the next one reaches the page as usual.
- Each half answers hover, press and focus on its own. The other half never shows a state that is not its own.
- Each half’s hit area reaches outward and above and below to the minimum target. Toward the other half it stops at the seam, or at the middle of the gap, so the two never overlap. What a narrow trailing half cannot reach toward the leading half it reaches outward instead, so it has its whole target without being drawn wider.
- The divider is decoration: it is hidden from assistive technology and takes no press. The two halves are two buttons whether a line or a gap is between them.
- In the default system the outline of the outlined variant, and the divider of the outlined, tonal and elevated variants, are the hairline, which is deliberately quiet: the leading half is identified by its label and the trailing half by its chevron. The divider on the filled variant is stronger, because a hairline cannot be seen on that fill.
- The label never truncates. At larger text sizes it wraps, and both halves grow in height together.
- `destructive` adds a color and nothing else: both halves keep their roles, names, states and keys, because the web has no role or state for a destructive action. On SwiftUI, where one exists, the leading half takes the destructive role.
- A disabled button with menu exposes both halves as disabled, takes them out of the focus order, presents no menu and draws its closed form, whatever `open` says.
- Debug builds report a missing label or menu label, and a menu with no items.

Known platform gaps:

- Web: only the leading half’s click reaches listeners on the button with menu. A menu item’s click stops at the button with menu, so a listener on an ancestor never hears it: listen on the item. The React wrappers of the menu items attach `onClick` to the element for this reason.
- SwiftUI has no property that says a button opens a menu, and no expanded state. The menu’s popover is attached to the trailing half; because the halves share one height and the trailing half ends the row, its bottom end is the whole button with menu’s. The binding is both the toggle and the close, so the close reason is not passed on. A menu slot that is empty even for a moment, such as a `ForEach` over items that load later, is an assertion failure in debug builds and stops a debug app. Enter, Space and Down Arrow on the halves, focus returning to the trailing half, and a click on the trailing half on macOS while the menu is open are not tested end to end.
- Flutter has no property that says a button opens a menu. The trailing half exposes whether the menu is open, but not that it opens one.
- Compose has no property that says a button opens a menu. The trailing half is a button with expand and collapse actions for its state. The menu itself carries the Compose gaps listed on the [menu](https://tanrei.chnotchy.com/components/menu/) page.
- React Native has no property that says a button opens a menu either. The trailing half exposes whether the menu is open, but not that it opens one.
- React Native: a press on the trailing half while the menu is open, focus returning to it, and the chevron’s turn have been tested without a device only.

### What you need to do

- Write both labels. A chevron alone is not a name, and “More” beside “Save” does not say what the menu holds.
- On a destructive button with menu, let the label say what is destroyed (“Delete project”, not “OK”): the error color tells only the people who see it (WCAG 1.4.1, use of color).
- On the web, Compose and React Native, set `open` from the toggle event and to false from the close event. The button with menu only asks. With `hoverOpen`, hover asks through the same toggle event.
- Do not move focus yourself when the menu closes. The button with menu returns it to the trailing half.
- Give every menu item a label, including items that show an icon.
- Leave room past the end edge of a narrow trailing half, so that its target does not overlap the next control.
