Actions
Button group
A row of related buttons: in a standard group the pressed button grows and pushes its neighbors; a connected group selects one or several options.
- COMPONENT_SPEC §3
- CSS · custom elements · React
- Open in the playground
Examples
Standard
Press and hold a button: it grows on a spring and its neighbors make room.
The live demo runs with JavaScript. Its code is below.
import { ButtonGroup, ButtonGroupItem } from '@kyralabs/m3exp-web/react';
export default function Standard() {
return (
<ButtonGroup aria-label="Formatting" size="m" buttonVariant="tonal">
<ButtonGroupItem id="bold" icon="format_bold" aria-label="Bold" />
<ButtonGroupItem id="italic" icon="format_italic" aria-label="Italic" />
<ButtonGroupItem id="underline" icon="format_underlined" aria-label="Underline" />
<ButtonGroupItem id="more">More</ButtonGroupItem>
</ButtonGroup>
);
}
<m3e-button-group label="Formatting" size="m" button-variant="tonal">
<m3e-button-group-item value="bold" icon="format_bold" label="Bold"></m3e-button-group-item>
<m3e-button-group-item value="italic" icon="format_italic" label="Italic"></m3e-button-group-item>
<m3e-button-group-item
value="underline"
icon="format_underlined"
label="Underline"
></m3e-button-group-item>
<m3e-button-group-item value="more">More</m3e-button-group-item>
</m3e-button-group>
Run the HTML version (custom elements, no framework).
Connected, single selection
A segmented row that replaces the segmented button: selectionMode="single" makes a radio group; disallowEmptySelection keeps one selected.
The live demo runs with JavaScript. Its code is below.
import { useState } from 'react';
import { ButtonGroup, ButtonGroupItem } from '@kyralabs/m3exp-web/react';
export default function Connected() {
const [view, setView] = useState<string>('week');
return (
<ButtonGroup
aria-label="View"
variant="connected"
selectionMode="single"
disallowEmptySelection
buttonVariant="tonal"
selectedKeys={[view]}
onSelectionChange={(keys) => {
const [key] = keys;
if (key !== undefined) setView(String(key));
}}
>
<ButtonGroupItem id="day">Day</ButtonGroupItem>
<ButtonGroupItem id="week">Week</ButtonGroupItem>
<ButtonGroupItem id="month">Month</ButtonGroupItem>
</ButtonGroup>
);
}
<m3e-button-group
variant="connected"
selection-mode="single"
disallow-empty-selection
button-variant="tonal"
label="View"
>
<m3e-button-group-item value="day">Day</m3e-button-group-item>
<m3e-button-group-item value="week" selected>Week</m3e-button-group-item>
<m3e-button-group-item value="month">Month</m3e-button-group-item>
</m3e-button-group>
Run the HTML version (custom elements, no framework).
Connected, multiple selection
selectionMode="multiple" makes each button a toggle.
The live demo runs with JavaScript. Its code is below.
import { ButtonGroup, ButtonGroupItem } from '@kyralabs/m3exp-web/react';
export default function Multiple() {
return (
<ButtonGroup
aria-label="Filters"
variant="connected"
selectionMode="multiple"
buttonVariant="outlined"
defaultSelectedKeys={['wifi']}
>
<ButtonGroupItem id="wifi" icon="wifi">
Wi-Fi
</ButtonGroupItem>
<ButtonGroupItem id="bluetooth" icon="bluetooth">
Bluetooth
</ButtonGroupItem>
<ButtonGroupItem id="location" icon="location_on">
Location
</ButtonGroupItem>
</ButtonGroup>
);
}
<m3e-button-group
variant="connected"
selection-mode="multiple"
button-variant="outlined"
label="Filters"
>
<m3e-button-group-item value="wifi" icon="wifi" selected>Wi-Fi</m3e-button-group-item>
<m3e-button-group-item value="bluetooth" icon="bluetooth">Bluetooth</m3e-button-group-item>
<m3e-button-group-item value="location" icon="location_on">Location</m3e-button-group-item>
</m3e-button-group>
Run the HTML version (custom elements, no framework).
Usage
Organize buttons with shape/width interaction. Connected groups replace segmented selection patterns.
API
React
<ButtonGroup>
Button group (COMPONENT_SPEC §3): standard (a pressed item grows by ExpandedRatio while its neighbors shrink, on the fast spatial spring) or connected (a segmented row whose items only morph their own corners). React Aria provides the buttons (useButton, or useToggleButtonGroupItem with react-stately useToggleGroupState when selectable); the motion, roving tabindex and overflow measurement come from m3e-elements' ButtonGroupLayout, the same code <m3e-button-group> runs. The overflow menu is a baseline <Menu> (Compose DropdownMenu) listing the hidden items; its button has Compose's built-in plain tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
children
required
|
ReactNode
|
<ButtonGroupItem> elements.
|
|
variant
|
"standard" | "connected"
|
standard |
Default standard: pressed items grow and push their neighbors. connected: one segmented row.
|
size
|
"xs" | "s" | "m" | "l" | "xl"
|
s |
Size of the group and its buttons. Default s.
|
shape
|
"round" | "square"
|
round |
Resting shape of the buttons. Default round.
|
buttonVariant
|
"elevated" | "filled" | "tonal" | "outlined" | "text"
|
filled |
Color configuration of the buttons. Default filled.
|
selectionMode
|
"none" | "single" | "multiple"
|
none (default): plain buttons. single: a radiogroup of radios (aria-checked).
multiple: toggle buttons (aria-pressed).
|
|
disallowEmptySelection
|
boolean
|
Keep at least one item selected (m3.material.io "selection-required"). | |
overflow
|
boolean
|
Standard groups: trailing items that do not fit collapse into a menu opened from a "More options" icon button at the end (Compose ButtonGroup overflowIndicator). | |
overflowLabel
|
string
|
Accessible name and tooltip of the overflow button. Default "More options". | |
selectedKeys
|
Iterable<Key>
|
||
defaultSelectedKeys
|
Iterable<Key>
|
||
isDisabled
|
boolean
|
||
onAction
|
(key: Key) => void
|
Called with the item's key when an item is pressed (any selection mode). | |
onSelectionChange
|
(keys: Set<Key>) => void
|
Also accepts HTML attributes not listed here.
<ButtonGroupItem>
One button of a <ButtonGroup> (rendered by the group, never on its own).
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
Key
|
Unique within the group; the key in selectedKeys. Defaults to the element's React key.
|
|
children
|
ReactNode
|
The label. Icon-only items need aria-label.
|
|
icon
|
string | ReactElement
|
Leading icon: a Material Symbols name or an <Icon>.
|
|
variant
|
"elevated" | "filled" | "tonal" | "outlined" | "text"
|
Overrides the group's buttonVariant for this item.
|
|
textValue
|
string
|
Overflow menu label when children is not a plain string.
|
|
isDisabled
|
boolean
|
||
aria-label
|
string
|
||
onPress
|
() => void
|
Called when the item is pressed (also from the overflow menu). |
Custom elements
<m3e-button-group>
import '@kyralabs/m3e-elements/button-group.js';
<m3e-button-group variant="connected" size="m" selection-mode="single" label="View"> (COMPONENT_SPEC §3). Renders the m3e-css .m3e-button-group contract inside itself (the host is display: contents) from its <m3e-button-group-item> children; ButtonGroupLayout adds the press width springs (standard groups), the interruptible shape springs, roving focus and overflow measurement.
variant:standard(default) |connected.size:xs|s(default) |m|l|xl.shape:round(default) |square.button-variant:filled(default) |tonal|elevated|outlined|text.selection-mode:none(default; plain buttons),single(role="radiogroup", radio items witharia-checked),multiple(toggle buttons witharia-pressed).disallow-empty-selectionkeeps one item selected (selection-requiredis its deprecated name, since 1.8; removed in 2.0).disableddisables every item.label: the group's accessible name.overflow(standard groups): trailing items that do not fit collapse into a menu opened from a "More options" icon button (overflow-labelrenames it). The menu is<m3e-menu variant="baseline">, loaded on first use. The button carries the plain tooltip Compose builds into ButtonGroupDefaults.OverflowIndicator (owner decision M9 #30): its name, above it (<m3e-tooltip>'s TooltipController, imported from thetooltip-popup.jsentry when the button is first rendered). It repeats the button's name, so it isaria-hidden(owner decision M9 #2).- Keyboard: Tab enters on one item (the last focused, else the checked one, else the first); ArrowLeft/ArrowRight (mirrored in RTL), Home and End move focus; Enter/Space activate.
- Events:
selection-changeon the group when the selection changes,detail: { value, selected, values }(changecarries the same detail and is deprecated since 1.8, removed in 2.0);actionon the<m3e-button-group-item>(bubbles) when it is activated,detail: { value }.
| Attribute | Property | Type | Notes |
|---|---|---|---|
variant
|
variant
|
string
|
|
size
|
size
|
string
|
|
shape
|
shape
|
string
|
|
button-variant
|
buttonVariant
|
string
|
|
selection-mode
|
selectionMode
|
string
|
|
disallow-empty-selection
|
disallowEmptySelection
|
boolean
|
Keeps one item selected (React Aria's disallowEmptySelection).
|
selection-required
|
selectionRequired
|
boolean
|
Deprecated.
Since 1.8: use disallowEmptySelection (disallow-empty-selection). Removed in 2.0. Keeps one item selected.
|
overflow
|
overflow
|
boolean
|
|
overflow-label
|
overflowLabel
|
string
|
|
label
|
label
|
string
|
|
disabled
|
disabled
|
boolean
|
| Event | Detail | Description |
|---|---|---|
selection-change
|
ButtonGroupChangeDetail
|
The selection changed. |
change
|
ButtonGroupChangeDetail
|
Deprecated.
Since 1.8: listen to selection-change (same detail). Removed in 2.0.
|
action
|
ButtonGroupActionDetail
|
An item was activated (from the <m3e-button-group-item>, bubbling).
|
<m3e-button-group-item>
import '@kyralabs/m3e-elements/button-group.js';
<m3e-button-group-item value="bold" icon="format_bold" selected>Bold</m3e-button-group-item> — data for one button of a <m3e-button-group> (it renders nothing itself). The label is the text content; an icon-only item needs label (its accessible name). variant overrides the group's button-variant for this item.
| Attribute | Property | Type | Notes |
|---|---|---|---|
value
|
value
|
string
|
|
selected
|
selected
|
boolean
|
Reflected. |
disabled
|
disabled
|
boolean
|
|
icon
|
icon
|
string
|
|
variant
|
variant
|
string
|
|
label
|
label
|
string
|
| Event | Detail | Description |
|---|---|---|
action
|
ButtonGroupActionDetail
|
Activated (it bubbles to the group). |
CSS
button-group.css
@import '@kyralabs/m3e-css/button-group.css';
Button group, standard and connected (COMPONENT_SPEC §3). Static layout here; the width redistribution on press, interruptible shape springs and overflow measurement are JavaScript (m3e-elements <m3e-button-group> and m3e-react <ButtonGroup>, both through m3e-elements' ButtonGroupLayout).
<div class="m3e-button-group m3e-button-group--size-s" role="group" aria-label="Text">
<button class="m3e-button m3e-button--filled m3e-button--size-s m3e-button--round
m3e-button-group__item" type="button">Bold</button>
…
</div>
<div class="m3e-button-group m3e-button-group--connected" role="radiogroup" aria-label="View">
<button class="m3e-button … m3e-button-group__item" role="radio" aria-checked="true">Day</button>
…
</div>
Variants: --standard (default) --connected. Sizes: --size-xs --size-s (default) --size-m --size-l --size-xl; give the items the matching .m3e-button--size-*. --overflow makes the group span its container so trailing items can collapse into the menu of the .m3e-button-group__overflow icon button (hidden items carry hidden). The overflow button is followed by its plain tooltip (ButtonGroupDefaults.OverflowIndicator wraps it in a TooltipBox, ButtonGroup.kt L306–311): a popover .m3e-tooltip (tooltip.css) with the button's name, aria-hidden because it repeats that name.
10 custom properties it reads
| Custom property | Default |
|---|---|
--m3e-comp-button-group-large-between-space
|
var(--m3e-sys-space-100) |
--m3e-comp-button-group-medium-between-space
|
var(--m3e-sys-space-100) |
--m3e-comp-button-group-small-between-space
|
12px |
--m3e-comp-button-group-x-large-between-space
|
var(--m3e-sys-space-100) |
--m3e-comp-button-group-x-small-between-space
|
18px |
--m3e-comp-connected-button-group-large-between-space
|
var(--m3e-sys-space-25) |
--m3e-comp-connected-button-group-medium-between-space
|
var(--m3e-sys-space-25) |
--m3e-comp-connected-button-group-small-between-space
|
2px |
--m3e-comp-connected-button-group-x-large-between-space
|
var(--m3e-sys-space-25) |
--m3e-comp-connected-button-group-x-small-between-space
|
var(--m3e-sys-space-25) |
Accessibility
none → role="group" with buttons; single → role="radiogroup", radios with aria-checked; multiple → role="group", aria-pressed buttons (React Aria useToggleButtonGroupItem semantics). One tab stop (last focused, else the checked radio, else the first); ArrowLeft/Right (mirrored in RTL), Home/End move focus without wrapping; Enter/Space activate. Overflow button: aria-haspopup="true" (≡ menu), aria-expanded, aria-controls while open; its tooltip repeats its name, so it is aria-hidden and not an aria-describedby target (owner decision M9 #2).
overflow (standard groups): when the items and their spacing do not fit, the indicator's width is reserved and leading items are shown while each fits (ButtonGroupMeasurePolicy L673–716); the rest are listed in a baseline menu (Compose DropdownMenu + DropdownMenuItem), re-measured on resize. The indicator has the plain tooltip Compose builds into ButtonGroupDefaults.OverflowIndicator (ButtonGroup.kt L306–311: TooltipBox(rememberTooltipPositionProvider(TooltipAnchorPosition.Above), PlainTooltipInternal(contentDescription), rememberTooltipState())): its name ("More options", overflowLabel / overflow-label), above it, with the §33 behavior (owner decision M9 #30).
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
widths keep fractions (Compose rounds each growth to whole device px); items use the size's button padding (Compose's clickableItem uses ButtonDefaults.ContentPadding, 24dp); the overflow indicator matches the group size (Compose: a default 40dp FilledIconButton) and selectable items show a check in the menu (Compose's menu items show no selection); the indicator's tooltip follows §33's web rules (shown on :focus-visible only, a 1.5s linger after the pointer leaves; owner decision M9 #1) and PlainTooltipInternal's paneTitle + assertive live region (Tooltip.kt L1418–1446) have no web equivalent: the tooltip is aria-hidden because it repeats the button's name (owner decision M9 #2).
Sources
Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from
m3.material.io, as cited below from COMPONENT_SPEC §3 and the m3e-css sources.
ButtonGroup.kt, ButtonGroupSamples.kt, ButtonGroup*Tokens.kt, ToggleButton.kt; m3.material.io/components/button-groups (specs, guidelines, accessibility) and its token DB.
ButtonGroupSmallTokens.kt, ConnectedButtonGroupSmallTokens.kt@19991f2c plus components/button-group-defaults.css (SOURCES register #21): the ButtonGroupDefaults values Compose renders (ExpandedRatio 0.15, width spring = fast spatial, release threshold 0.75 / max wait 1000ms from EnlargeOnPressNode) and the per-size values Compose lacks from the m3.material.io token DB (md.comp.button-group.*; Compose spaces and shapes every size with the small tokens).
- Connected outer corners: round (CornerFull); square groups keep the inner corner size outside too (m3.material.io Measurements). Checked = CornerFull (
connectedButtonCheckedShape); pressed wins over checked (ToggleButton.ktshapeByInteraction). - Items keep their intrinsic width (standard groups hug them, top-aligned); connected groups span their container with equal-width items, never narrower than the button minimum width (58dp ≥ the 48dp XS/S connected minimum).
button-group.css sources
(compose/material3 @ 19991f2c, ButtonGroup.kt; m3.material.io Button groups):
- Standard spacing: ButtonGroupDefaults.HorizontalArrangement = ButtonGroupSmallTokens.BetweenSpace (12dp, Compose uses it for every size); XS 18, M/L/XL 8 from the m3.material.io token DB (button-group-defaults.css), "to ensure a minimum accessible target size of 48dp".
- Items keep their intrinsic width and the group hugs them (ButtonGroupMeasurePolicy measures maxIntrinsicWidth), top-aligned (verticalAlignment = Alignment.Top). Labels never wrap (clickableItem: maxLines 1, softWrap false).
- Connected: ButtonGroupDefaults.ConnectedSpaceBetween (2dp) and the connected leading / middle / trailing ToggleButtonShapes: outer corners round (CornerFull; square groups keep the inner corner size there, m3.material.io Measurements), inner corners InnerCornerCornerSize, pressed inner corners PressedInnerCornerCornerSize, checked = CornerFull (connectedButtonCheckedShape); pressed wins over checked (ToggleButton.kt shapeByInteraction). Per-size corners from the token DB. The shape morphs on the fast spatial spring (ToggleButton.kt L176).
- Connected groups span the width of their container with equal-width items (m3.material.io Button groups › Guidelines › Container); items never shrink below their content or the button minimum width. Compose has no connected layout of its own (its samples use a FlowRow).