Skip to content

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.

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.

React HTML
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.

React HTML
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.

React HTML
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 with aria-checked), multiple (toggle buttons with aria-pressed). disallow-empty-selection keeps one item selected (selection-required is its deprecated name, since 1.8; removed in 2.0). disabled disables 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-label renames 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 the tooltip-popup.js entry when the button is first rendered). It repeats the button's name, so it is aria-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-change on the group when the selection changes, detail: { value, selected, values } (change carries the same detail and is deprecated since 1.8, removed in 2.0); action on 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.kt shapeByInteraction).
  • 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).