Skip to content
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
Getting startedWebReactSwiftUIJetpack ComposeFlutterReact NativePresetsYour own design systemUsing Tanrei with AI agentsBrandDesign tokensDesign systems and customizationTheme StudioColorTypographyIconsShapeElevationMotionInteraction statesBehaviourLayoutAccessibilityWordsColor tokensTypescale tokensShape tokensElevation tokensMotion tokensState tokensSpace and measure tokensComponentsButtonMain action buttonSegmented buttonButton groupButton with menuToolbarCopy buttonCheckboxRadio buttonSwitchChipMenuSliderSlider fieldCalendarDateTimePickerField groupRadio groupCheckbox groupSelectSelect fieldMenubarFile uploadFile picker AdvancedDrop zoneText input AdvancedText fieldChip fieldFieldCombobox AdvancedCombobox fieldNumber input AdvancedNumber fieldColor input AdvancedColor fieldOne-time-code input AdvancedOne-time-code fieldEmbedded text input AdvancedChat composerCardDividerModal layer AdvancedDialogListList rowBottom sheetSide sheetCarouselCode blockTablePopoverStepsAccordionDescription listFigureApp shellPage headerTree viewTimelineImage previewHandleResizable frameScroll areaBadgeProgress indicatorMeterNumber tickerLoading indicatorSnackbarTooltipKeyboard shortcutTagInline codeCalloutAvatarEmpty stateTag groupSkeletonFile itemSwatchMarquee textTabsTab viewApp barNavigation barNavigation drawerSearchLinkPaginationPrevious and next linksStepperBreadcrumbsTable of contentsLink cardFAB menuStatusHow Tanrei is builtContributingLicense and attribution
HomeComponentsButton group

Button group

Lays out a few related buttons in a row or a column, spaced apart or joined into one shape, places the completing and the backing-out actions where people expect them, and names the buttons as a group.

content_copyView as Markdown
Category
Actions
Tokens
comp.button-group
Level
Base

Overview

Web
tanrei-button-group
schedule
SwiftUI
TanreiButtonGroup
schedule
Jetpack Compose
TanreiButtonGroup
schedule
Flutter
TanreiButtonGroup
schedule
React Native
ButtonGroup
schedule

Generated from docs/status/status.json, specs/button-group.spec.json

Standard and connected

CancelSave draftPublishformat_boldformat_italicformat_underlined

Usage

The items are the Buttons you already use, labelled or icon-only, with their own props and events. The group only places them, joins them when connected, and names them when it has a label.

The Web and React samples show the current spec. SwiftUI, Compose, Flutter and React Native are lagging: they still ship the previous revision of the button group, a single row with no orientation, fullWidth, separation or order flags, whose connected items always stand a narrow gap apart, and whose icon-only items are a separate icon button. Their samples show that revision until they catch up.

<!-- dismissive goes to the start of a row, confirming to its end; in a column they change places. -->
<tanrei-button-group>
  <tanrei-button variant="outlined" dismissive>Cancel</tanrei-button>
  <tanrei-button variant="tonal">Save draft</tanrei-button>
  <tanrei-button variant="filled" confirming>Publish</tanrei-button>
</tanrei-button-group>

<!-- A column as wide as its container: Allow is drawn above Not now. -->
<tanrei-button-group orientation="vertical" full-width>
  <tanrei-button variant="tonal" dismissive>Not now</tanrei-button>
  <tanrei-button variant="tonal" confirming>Allow</tanrei-button>
</tanrei-button-group>

<!-- A label names the group. Each toggle keeps its own selected. -->
<tanrei-button-group variant="connected" label="Text style">
  <tanrei-button variant="tonal" toggle label="Bold"><span slot="icon">format_bold</span></tanrei-button>
  <tanrei-button variant="tonal" toggle label="Italic"><span slot="icon">format_italic</span></tanrei-button>
  <tanrei-button variant="tonal" toggle label="Underline"><span slot="icon">format_underlined</span></tanrei-button>
</tanrei-button-group>

<script type="module">
  import '@tanrei/web/components/button-group';
  import '@tanrei/web/components/button';
  // The group fires no events of its own: listen on the buttons, as anywhere.
  document.querySelector('[label="Bold"]').addEventListener('change', (e) => setBold(e.detail.selected));
</script>
import { Button, ButtonGroup } from '@tanrei/react';

// confirming and dismissive are attributes the group reads from its children.
<ButtonGroup>
  <Button variant="outlined" dismissive onClick={cancel}>Cancel</Button>
  <Button variant="tonal" onClick={saveDraft}>Save draft</Button>
  <Button variant="filled" confirming onClick={publish}>Publish</Button>
</ButtonGroup>

<ButtonGroup orientation="vertical" fullWidth>
  <Button variant="tonal" dismissive onClick={notNow}>Not now</Button>
  <Button variant="tonal" confirming onClick={allow}>Allow</Button>
</ButtonGroup>

<ButtonGroup variant="connected" label="Text style">
  <Button variant="tonal" toggle selected={bold} label="Bold" onChange={(e) => setBold(e.detail.selected)}><span slot="icon">format_bold</span></Button>
  <Button variant="tonal" toggle selected={italic} label="Italic" onChange={(e) => setItalic(e.detail.selected)}><span slot="icon">format_italic</span></Button>
</ButtonGroup>
import Tanrei

TanreiButtonGroup {
    TanreiButton("Cancel", variant: .outlined) { cancel() }
    TanreiButton("Save draft", variant: .tonal) { saveDraft() }
    TanreiButton("Publish") { publish() }
}

TanreiButtonGroup(variant: .connected, label: "Text style") {
    TanreiIconButton("Bold", isSelected: $bold, variant: .tonal) { Image(systemName: "bold") }
    TanreiIconButton("Italic", isSelected: $italic, variant: .tonal) { Image(systemName: "italic") }
    TanreiIconButton("Underline", isSelected: $underline, variant: .tonal) { Image(systemName: "underline") }
}
import io.github.chnotchy.tanrei.components.TanreiButton
import io.github.chnotchy.tanrei.components.TanreiButtonGroup
import io.github.chnotchy.tanrei.components.TanreiButtonGroupVariant
import io.github.chnotchy.tanrei.components.TanreiIconButton
import io.github.chnotchy.tanrei.primitives.TanreiText
import io.github.chnotchy.tanrei.tokens.TanreiButtonVariant
import io.github.chnotchy.tanrei.tokens.TanreiIconButtonVariant

TanreiButtonGroup {
    TanreiButton(text = "Cancel", onClick = ::cancel, variant = TanreiButtonVariant.Outlined)
    TanreiButton(text = "Save draft", onClick = ::saveDraft, variant = TanreiButtonVariant.Tonal)
    TanreiButton(text = "Publish", onClick = ::publish)
}

// contentDescription names the group. Compose has no group role: it is a traversal group.
TanreiButtonGroup(variant = TanreiButtonGroupVariant.Connected, contentDescription = "Text style") {
    TanreiIconButton(
        onClick = {},
        contentDescription = "Bold",
        variant = TanreiIconButtonVariant.Tonal,
        checked = bold,
        onCheckedChange = { bold = it },
    ) { TanreiText("format_bold") }
    TanreiIconButton(
        onClick = {},
        contentDescription = "Italic",
        variant = TanreiIconButtonVariant.Tonal,
        checked = italic,
        onCheckedChange = { italic = it },
    ) { TanreiText("format_italic") }
}
import 'package:tanrei/tanrei.dart';

TanreiButtonGroup(
  children: <Widget>[
    TanreiButton(variant: TanreiButtonVariant.outlined, onPressed: cancel, child: const Text('Cancel')),
    TanreiButton(variant: TanreiButtonVariant.tonal, onPressed: saveDraft, child: const Text('Save draft')),
    TanreiButton(onPressed: publish, child: const Text('Publish')),
  ],
);

TanreiButtonGroup(
  variant: TanreiButtonGroupVariant.connected,
  semanticLabel: 'Text style',
  children: <Widget>[
    TanreiIconButton(
      isSelected: bold,
      onPressed: () => setState(() => bold = !bold),
      variant: TanreiIconButtonVariant.tonal,
      semanticLabel: 'Bold',
      icon: const Icon(AppSymbols.formatBold),
    ),
    TanreiIconButton(
      isSelected: italic,
      onPressed: () => setState(() => italic = !italic),
      variant: TanreiIconButtonVariant.tonal,
      semanticLabel: 'Italic',
      icon: const Icon(AppSymbols.formatItalic),
    ),
  ],
);
import { Button, ButtonGroup, IconButton } from '@tanrei/react-native';

<ButtonGroup>
  <Button variant="outlined" onPress={cancel}>Cancel</Button>
  <Button variant="tonal" onPress={saveDraft}>Save draft</Button>
  <Button onPress={publish}>Publish</Button>
</ButtonGroup>

<ButtonGroup variant="connected" accessibilityLabel="Text style">
  <IconButton variant="tonal" accessibilityLabel="Bold" toggle selected={bold} onSelectedChange={setBold}>
    format_bold
  </IconButton>
  <IconButton variant="tonal" accessibilityLabel="Italic" toggle selected={italic} onSelectedChange={setItalic}>
    format_italic
  </IconButton>
</ButtonGroup>

A row or a column, and the order of actions

orientation lays the buttons out in one row (horizontal, the default) or stacks them in one column (vertical), where every button is as wide as the column. fullWidth makes the group fill its container: a row’s buttons share the width equally, and a column’s buttons are each that wide. That is the form for the actions at the foot of a sheet or a narrow panel.

Two flags on a button tell the group where it belongs, so that the two orientations stay consistent with each other:

Flag The button In a row In a column
confirming completes the task: OK, Save, Next, Delete at the end at the top
dismissive backs out of it: Cancel, Back, Not now at the start at the bottom
neither anything else between the two, in the order given between the two, in the order given

The same three buttons in a row and in a column

CancelSave draftPublishCancelSave draftPublish

Full width: a row that shares the width, and a column

Not nowAllow
Not nowAllow

Keyboard focus and screen readers follow the placed order, so everyone meets the actions in the order they are drawn. The flags describe the task, not the tone: in “Cancel · Delete”, Delete is the confirming one. A destructive action is told apart by its own look (the Button’s destructive) and its label, not by its place. A dialog and a side sheet place their own actions by the same two flags.

Joined by a divider or by a gap

A connected group joins its buttons in one of two ways. With a divider the buttons touch, their corners are square where they meet, and a line marks each 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 the button group apart, and separation (auto, divider, gap) sets one group. A standard group ignores it.

Connected by a divider and by a gap, in a row and in a column

CutCopyPasteCutCopyPasteaddremoveaddremove

Under a divider, a seam carries a line only where the two buttons rest on the same fill. Between a selected toggle and an unselected one, or a filled button and an outlined one, the edge of the fill already marks the seam, so no line is drawn there.

Toggles joined by a divider: no line beside the selected one

format_boldformat_italicformat_underlinedformat_strikethrough

Specs

Connected: outlined, large and with a disabled item

CutCopyPastePreviousTodayNextCutCopyPaste

Anatomy

Part Description
1 group The row or column that holds the Buttons. It places and spaces the Buttons, joins them in the connected variant, and names them as a group. It draws nothing of its own but the dividers of a connected group.
2 button A Tanrei Button placed in the group: icon-only, label-only or both, its icon on either side, a toggle or not. It is drawn from its own tokens and keeps its own states, focus, events and semantics.
3 inner-corner · optional In the connected variant, a corner of a Button that faces its neighbour. The group sets it.
4 outer-corner · optional In the connected variant, a corner at an end of the joined shape: the start corners of the first Button and the end corners of the last in a row, the top corners of the first and the bottom corners of the last in a column. The group sets it.
5 divider · optional In the connected variant under divider, the line on the seam between two Buttons that touch. The group draws it; under gap there is none.

Properties

Property Type Default Description
variant standard | connected "standard" standard spaces the Buttons apart. connected joins them into one shape with rounded outer ends, by a divider or by a narrow gap (separation), for a closely related set (Bold, Italic, Underline; Previous, Today, Next).
orientation horizontal | vertical "horizontal" horizontal lays the Buttons out in one row; vertical stacks them in one column, every Button as wide as the column. The confirming and dismissive Buttons change places with the orientation (BGR-13, BGR-14).
separation auto | divider | gap "auto" How the Buttons of a connected group are joined. divider: they touch, and a line is drawn on each seam. gap: they stand a narrow gap apart, with tighter corners where they face each other. Auto takes comp.button-group.connected.separation, the design system's answer (divider in the default system). Ignored while variant is standard.
fullWidth boolean false The group fills its container's width and its Buttons share it equally (in a column, each is the container's width). For the actions at the foot of a dialog, a picker or a side sheet.
label string "" The accessible name of the group ("Text style"). Optional: a group of actions that are each named by their own label needs none.

Items

The kinds of item the component repeats. A fixture lists its items in order; each platform names each kind in its own idiom.

button

A Button in the group: the button component, placed, spaced and (when connected) given its corners and minimum size by the group, otherwise drawn and behaving as it does anywhere.

Property Type Default Description
variant filled | tonal | elevated | outlined | text "filled" Its emphasis, as the Button's variant.
size small | medium | large "medium" Its height, as the Button's size. Give every Button of a group the same size.
width default | narrow | wide "default" An icon-only Button's width, as the Button's width. A connected group still widens it to its minimum (BGR-3).
icon string "" The glyph in the Button's icon slot, by its name in the system's icon set. Empty for a label-only Button. It is how a fixture fills that slot; in an application the icon slot is filled as the Button's own spec says.
iconPosition leading | trailing "leading" Which side of the label the icon is on, as the Button's iconPosition.
label string "" Its accessible name, as the Button's label prop. Required for an icon-only Button.
toggle boolean false As the Button's toggle.
selected boolean false As the Button's selected.
disabled boolean false As the Button's disabled.
confirming boolean false The Button completes the task (OK, Save, Next, Delete). The group places it at the end of a row and at the top of a column (BGR-13, BGR-14). It changes the Button's place only, never its look (BGR-19).
dismissive boolean false The Button backs out of the task (Cancel, Back, Not now). The group places it at the start of a row and at the bottom of a column (BGR-13, BGR-14). It changes the Button's place only, never its look (BGR-19).
Slot Description
label Its label, as the Button's default slot. A fixture item's content is this label; an icon-only item has none.
icon Its glyph, as the Button's icon slot. A fixture fills it through the icon prop.
Event Description
click The Button was activated, as the Button's click.
change A toggle Button was switched, as the Button's change.
Spec Web SwiftUI Jetpack Compose Flutter React Native
Item tanrei-button TanreiButton TanreiButton TanreiButton Button
variant · Property variant variant variant variant variant
size · Property size size size size size
width · Property width width width width width
icon · Property slot="icon" icon icon icon icon
iconPosition · Property iconPosition iconPosition iconPosition iconAlignment iconPosition
label · Property label label contentDescription semanticLabel accessibilityLabel
toggle · Property toggle isSelected onCheckedChange isSelected toggle
selected · Property selected isSelected checked isSelected selected
disabled · Property disabled disabled enabled onPressed disabled
confirming · Property confirming tanreiGroupPlacement(.confirming) Modifier.confirming() TanreiButtonGroupItem.placement confirming
dismissive · Property dismissive tanreiGroupPlacement(.dismissive) Modifier.dismissive() TanreiButtonGroupItem.placement dismissive
click · Event click action onClick onPressed onPress
change · Event change isSelected onCheckedChange onPressed onSelectedChange
Notes A tanrei-button child, as anywhere: its label is its content and its glyph a tanrei-icon in its icon slot. confirming and dismissive are boolean attributes on the child that the group reads (a MutationObserver re-places the items when they change, BGR-11); the Button itself draws nothing for them. A TanreiButton child, as anywhere. A toggle is a Button given an isSelected binding. The two flags are one modifier, .tanreiGroupPlacement(.confirming) or (.dismissive), a LayoutValueKey the group's layout reads, as .layoutPriority is read by a stack; one enum cannot be both (BGR-18 cannot arise). A TanreiButton child, as anywhere; a toggle is a Button given checked and onCheckedChange, and enabled is disabled's inverse. The two flags are modifiers of the group's scope, Modifier.confirming() and Modifier.dismissive(), parent data the group's layout reads, as RowScope.weight is. A TanreiButton child, as anywhere; a null onPressed disables it, and a non-null isSelected makes it a toggle. The two flags are one ParentDataWidget, TanreiButtonGroupItem(placement: TanreiGroupPlacement.confirming | .dismissive, child:), as Flexible is for a Flex; one enum cannot be both (BGR-18 cannot arise). A Button child, as anywhere. confirming and dismissive are props on the child that the group reads from its children; the Button itself draws nothing for them.

States

Behavior

Requirements in EARS form. Every platform implementation is tested against these ids.

ID Requirement
BGR-1 WHILE orientation is horizontal THE button group SHALL lay out its items in one row, in the order BGR-13 gives, each at its own size and width unless fullWidth is true (BGR-17): WHILE variant is standard comp.button-group.standard.gap apart and centred on the row's middle; WHILE variant is connected joined as its separation names (BGR-21) — comp.button-group.connected.gap apart under gap, touching under divider — and each stretched to the height of the tallest item (a Button's height is a minimum), so that the joined shape stays even when a label wraps or text is enlarged.
BGR-2 WHILE variant is connected and orientation is horizontal THE button group SHALL give the start corners of the first item and the end corners of the last the radius comp.button-group.connected.horizontal.outer-shape, and each corner that faces a neighbouring item the radius comp.button-group.connected.inner-shape WHILE its separation is gap and no radius, a square corner, WHILE its separation is divider (BGR-21), each capped at half the item's height and eased by sys.shape.corner-smoothing; the item's fill, outline, state layer, ripple and focus ring SHALL follow the resulting shape. WHILE variant is standard THE button group SHALL leave every corner of every item as the item draws it.
BGR-3 WHILE variant is connected THE button group SHALL make each item at least sys.target.touch for touch input and sys.target.pointer for fine pointers along the direction in which it meets its neighbours — wide in a row, high in a column — because joined items cannot extend a hit area over each other: an icon-only Button narrower than that widens its container around its centred icon, and a Button lower than that in a column grows its container around its centred content. Across the other direction the items SHALL extend their hit areas as BTN-5 says. A platform that cannot tell the input type SHALL use sys.target.touch.
BGR-4 WHILE variant is standard and the hit areas that two neighbouring items extend (BTN-5) overlap THE button group SHALL give every press on an item's visible bounds to that item, and a press in the gap between two items to the nearer one, so that neighbouring hit areas meet at the middle of the gap. comp.button-group.standard.gap is what keeps each item's target whole there: at sys.target.touch minus an item's width (in a row) or height (in a column) or more, no hit areas overlap and every item keeps its full target between its neighbours, as the default system's gap does for every Button size; a layer that narrows the gap narrows the targets between items.
BGR-5 THE button group SHALL NOT restyle its items beyond BGR-2, BGR-3, BGR-12, BGR-16, BGR-17, BGR-22 and BGR-23: each item SHALL render from its own tokens, answer hover, focus and press itself, switch itself WHILE its toggle is true, emit its own events, keep its own semantics and be its own Tab stop in the order BGR-15 gives; the group itself SHALL NOT take focus, SHALL NOT handle arrow keys and SHALL keep no selection of its own.
BGR-6 WHILE label is not empty THE button group SHALL expose itself to assistive technology as a group named by label that contains its items; WHILE label is empty THE button group SHALL expose no element of its own, only its items, unless the product names it (BGR-25).
BGR-7 WHERE orientation is horizontal, fullWidth is false and the row is wider than the space its layout gives it THE button group SHALL narrow its labelled Buttons in proportion to their widths, down to the width of their longest word, letting their labels wrap as a Button's label does when its layout gives it less width, and SHALL keep icon-only Buttons at their width; it SHALL NOT wrap the row onto a second line or scroll it, and in debug builds SHALL report a row that still does not fit.
BGR-8 WHEN the layout direction is right-to-left THE button group SHALL, in a row, put its start at the right — the dismissive items at the right and the confirming items at the left (BGR-13), the first placed item's outer corners on its right — and mirror which corners are inner corners; in a column it SHALL keep the top-to-bottom order of BGR-14. Each item's content SHALL follow the layout direction as the Button's does.
BGR-9 WHEN variant, orientation or separation names a value the button group does not define THE button group SHALL render with that prop's default (standard, horizontal, auto), and in debug builds report the unknown value.
BGR-10 WHILE the group holds fewer than two items, or content that is not a Tanrei Button, THE button group SHALL still render it, a single item keeping every corner as it draws it, and in debug builds report it; a platform that cannot inspect its content SHALL state the rule in its README and docs page.
BGR-11 WHEN an item becomes disabled, is added or is removed, or its confirming or dismissive changes, THE button group SHALL place its items again by BGR-13 and BGR-14 and recompute which corners are inner and outer corners, where its dividers are (BGR-24) and which items carry the outer padding of BGR-23, at once, without animation; a disabled item SHALL keep its place, its gap or its dividers, and its corners, and an item that has focus while it moves SHALL keep it.
BGR-12 WHILE orientation is vertical THE button group SHALL lay out its items in one column, top to bottom in the order BGR-14 gives, comp.button-group.standard.gap apart WHILE variant is standard and, WHILE variant is connected, comp.button-group.connected.gap apart under gap and touching under divider (BGR-21), and SHALL make every item as wide as the column — the widest item's width, or the container's width WHILE fullWidth is true — its content centred as a Button centres it in a wider container. WHERE the column is given less width than its widest item every item SHALL take that width and its label SHALL wrap as BGR-7 says; the column SHALL NOT scroll.
BGR-13 WHILE orientation is horizontal THE button group SHALL place its items from start to end in three runs: first the items whose dismissive is true, then the items with neither flag, then the items whose confirming is true; within each run the items SHALL keep the order they were given in. The start is the left in a left-to-right layout and the right in a right-to-left one (BGR-8).
BGR-14 WHILE orientation is vertical THE button group SHALL place its items from top to bottom in three runs: first the items whose confirming is true, then the items with neither flag, then the items whose dismissive is true; within each run the items SHALL keep the order they were given in, the first on top, in either layout direction.
BGR-15 THE button group SHALL make the focus order, and the order in which assistive technology reads its items, the placed order of BGR-13 and BGR-14 — start to end in a row, top to bottom in a column — whatever order the items were given in; a platform whose visual reordering the focus order does not follow (the web's CSS order) SHALL reorder the items themselves in the tree that focus and assistive technology walk.
BGR-16 WHILE variant is connected and orientation is vertical THE button group SHALL give the top corners of the first item and the bottom corners of the last the radius comp.button-group.connected.vertical.outer-shape, and every other corner of every item comp.button-group.connected.inner-shape WHILE its separation is gap and no radius, a square corner, WHILE its separation is divider (BGR-21), each capped at half the item's height and eased by sys.shape.corner-smoothing; the item's fill, outline, state layer, ripple and focus ring SHALL follow the resulting shape.
BGR-17 WHILE fullWidth is true and orientation is horizontal THE button group SHALL take the whole width its container gives it and divide it, less the gaps (none under divider), into equal shares, one per item, whatever each item holds: a label shorter than its share SHALL be centred in it, a label longer SHALL wrap as BGR-7 says, and an icon-only Button SHALL widen its container around its centred icon. WHERE a share is narrower than an item's minimum width (BGR-3, or an icon-only Button's own width) THE button group SHALL give that item its minimum, share the rest equally among the others, and in debug builds report it. WHILE fullWidth is false THE button group SHALL take its natural width.
BGR-18 IF an item's confirming and dismissive are both true THE button group SHALL place it among the items with neither flag and in debug builds report it.
BGR-19 THE button group SHALL NOT change an item's look, emphasis or semantics for its confirming or dismissive: the flags decide its place only. A destructive confirming action (Delete) takes its look from its own variant and its own destructive (BTN-40), and a Button is not made filled, or destructive, by being confirming.
BGR-20 THE button group 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 group is shown THE button group SHALL draw itself for the new direction at once, without a motion of its own, keeping what it shows and its state.
BGR-21 WHILE variant is connected THE button group SHALL join its items as its separation names: WHILE separation is auto, as comp.button-group.connected.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 items SHALL stand comp.button-group.connected.gap apart with nothing drawn between them and the inner corners of BGR-2 and BGR-16; under divider the items SHALL touch, with square inner corners and the divider of BGR-22 on each seam. WHILE variant is standard THE button group SHALL ignore separation and space its items by comp.button-group.standard.gap. WHEN separation, or the token it follows, changes THE button group SHALL draw the other form at once, without animation, keeping the order, the focus and the state of its items. A platform that cannot draw a choice SHALL fall back to the first value, divider, and state that in its README.
BGR-22 WHILE variant is connected and its separation is divider THE button group SHALL draw, on each seam between two neighbouring items that BGR-24 says carries one, a line comp.button-group.connected.divider.thickness thick, in the color BGR-24 names (comp.button-group.connected.divider.color, or comp.button-group.connected.divider.on-fill.color between two emphasis fills), centred on the seam, over the items' fills and state layers, running along the seam — top to bottom in a row, start to end in a column — and stopping comp.button-group.connected.divider.inset short of each end of it. The line SHALL take no space of its own, SHALL NOT take a press (a press on it goes to the item under the point), SHALL NOT be exposed to assistive technology and SHALL keep its color whatever state its neighbours are in. Each item SHALL draw neither its outline nor the ring of its elevation along an edge that meets a seam, whether or not a line is drawn on it, so that the divider is the only line a seam can carry and the outline of an outlined group runs once around the joined shape, in the color of its rules in the default system; an item's state layer, ripple and focus ring SHALL still follow that item's own shape.
BGR-23 WHILE variant is connected and orientation is horizontal THE button group SHALL add comp.button-group.connected.horizontal.outer-inset to the inline padding of the first item at its start and of the last item at its end, under either separation, so that content beside a rounded outer end looks as far from it as from the tighter corner on its other side: a labelled Button SHALL be wider by that amount with its content moved inward by it, and an icon-only Button SHALL be wider by that amount with its glyph centred in what is left, half of it nearer its neighbour. A single item (BGR-10) SHALL get none; a column SHALL add none, its ends not being full (BGR-16); WHILE fullWidth is true the shares of BGR-17 SHALL be divided first and the padding taken inside the end items' shares. In a right-to-left layout the start is the right, as BGR-8 says.
BGR-24 WHILE variant is connected and its separation is divider THE button group SHALL draw the line of BGR-22 on a seam only WHILE the two items it joins rest on the same fill: the colors from which they render their containers at rest — comp.button.outlined.container.color for an outlined Button, and so on for each variant, and for a toggle the selected or the unselected part of its variant (BTN-15) — are the same color in the active scheme, two containers that have no fill counting as the same. WHILE the two fills differ (a selected toggle beside an unselected one, a filled Button beside an outlined one) THE button group SHALL draw no line on that seam, because the edge of the fill already marks it, and the two items SHALL still draw neither outline nor elevation ring along it (BGR-22). A toggle of the text variant has no fill, selected or not, so its seams keep their lines. THE button group SHALL draw the line in comp.button-group.connected.divider.color, and in comp.button-group.connected.divider.on-fill.color WHILE both items rest on an emphasis fill: a filled Button that is not a toggle, or a selected toggle of the filled, tonal, elevated or outlined variant. It SHALL compare the fills the items have at rest: hover, focus and press SHALL neither add nor remove a line nor change its color, and a disabled item SHALL be compared by the look it has when enabled, so that disabling an item moves no line (BGR-11). WHEN an item's selected, toggle or variant changes THE button group SHALL add, remove or recolor the lines beside it at once, without animation.
BGR-25 WHERE the platform has ARIA attributes (the web), the host carries its group role (BGR-6) itself, so what the product writes on it is not moved, but for a name: WHILE label is not empty THE button group SHALL hold the product's aria-label and aria-labelledby on that host as their data-aria- twins, so that label is its whole name although the platform gives aria-labelledby the precedence, and WHEN label is emptied SHALL give them back as they were written; WHILE label is empty a name the product gives so — aria-label, or aria-labelledby naming an element — SHALL be its name. WHILE label is empty and the product names it so THE button group SHALL be exposed as a group named by that name, as with a label: a group the product names is a named group, and a name on an element with no role would name nothing. WHEN the product removes one, or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaLabelledByElements) to null, THE button group SHALL take it away.

Platform name mapping

Parity does not mean identical APIs. Each platform uses its own idiom for the same contract.

Spec Web SwiftUI Jetpack Compose Flutter React Native
Component tanrei-button-group TanreiButtonGroup TanreiButtonGroup TanreiButtonGroup ButtonGroup
variant · Property variant variant variant variant variant
orientation · Property orientation orientation orientation orientation orientation
separation · Property separation separation separation separation separation
fullWidth · Property fullWidth fullWidth fullWidth fullWidth fullWidth
label · Property label label contentDescription semanticLabel accessibilityLabel
Notes <tanrei-button-group variant="connected" orientation="vertical" full-width label="Text style"> holds tanrei-button children. It places them without moving them in the author's DOM: its shadow root has three slots in the order of the orientation (dismissive, unflagged, confirming in a row; confirming, unflagged, dismissive in a column) and assigns each child to one with manual slot assignment, so focus and the accessibility tree, which walk the flat tree, follow the placed order (BGR-15) and CSS order is not used. It sets role group and aria-label on its own element only while label is not empty (ElementInternals is not used for the role: axe-core does not see semantics set through it). It gives a connected item its corners and minimum size through CSS custom properties that Button reads on its container (--tanrei-group-start-radius, --tanrei-group-end-radius, and in a column the top and bottom radii, --tanrei-group-min-width, --tanrei-group-min-height); these are an internal contract between Tanrei elements, not a styling API. Each item also learns its place in the group through an internal controller, because three things cannot be done with custom properties alone: a label that may wrap (BGR-7, BGR-17), corners that never animate and pill ends held to half the measured height (BGR-11), and a hit area that stops at half the gap (BGR-4). It reports other children in debug builds (BGR-10). separation is an attribute (auto, divider, gap); auto reads the name in --tanrei-comp-button-group-connected-separation. Under divider the group draws each rule itself, in its shadow root over the seam, and tells each item through the same internal controller which of its edges meet a seam (no outline, no ring) and how much padding to add at an outer end (BGR-22, BGR-23). Owed on the web: orientation="vertical" must really stack the items (BGR-12, BGR-14, BGR-16) — today the element and the review gallery draw every vertical fixture as a row — and its fixtures must be drawn as columns in the gallery and the baselines. Lagging: ships an earlier revision, a row of Buttons and the retired icon-only kind with no orientation, placement flags or full width. The target: TanreiButtonGroup(variant:, orientation:, fullWidth:, label:) { TanreiButton … }, a custom Layout that places its subviews by BGR-13 and BGR-14 and gives them .accessibilitySortPriority in that order so VoiceOver and keyboard focus follow it (BGR-15). The group passes a connected item its corners and minimum size through an environment value that TanreiButton reads (internal to the package). With a label it is .accessibilityElement(children: .contain) with .accessibilityLabel(label). The group reads its content through a variadic view, as the menu and tabs do, so it reports fewer than two items and content that is not a Tanrei Button in debug builds (BGR-10). Also owed: separation (auto, divider, gap), the divider of BGR-22, the outer padding of BGR-23 and the nine new fixtures; reading comp.button-group.connected.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Lagging: ships an earlier revision, a row of Buttons and the retired icon-only kind with no orientation, placement flags or full width. The target: TanreiButtonGroup(variant, orientation, fullWidth, contentDescription) { TanreiButton(…) }, a custom Layout whose items read a CompositionLocal for their corners and minimum size (internal to the library). It places the items by BGR-13 and BGR-14 and sets traversalIndex in that order so TalkBack and focus follow it (BGR-15). With a content description it is semantics { isTraversalGroup = true; contentDescription = … }; Compose has no group role. Debug builds report content that is not a Tanrei Button, and a group of fewer than two items (BGR-10). Also owed: separation (auto, divider, gap), the divider of BGR-22, the outer padding of BGR-23 and the nine new fixtures; reading comp.button-group.connected.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Lagging: ships an earlier revision, a row of Buttons and the retired icon-only kind with no orientation, placement flags or full width. The target: TanreiButtonGroup(variant:, orientation:, fullWidth:, semanticLabel:, children: [TanreiButton(…)]), a MultiChildRenderObjectWidget that places its children by BGR-13 and BGR-14 and wraps each in a FocusTraversalOrder and Semantics(sortKey:) in that order (BGR-15); the items read an InheritedWidget for their corners and minimum size (internal to the package). With a label it is Semantics(container: true, label:, explicitChildNodes: true). Children of another type are reported in debug builds (BGR-10). Also owed: separation (auto, divider, gap), the divider of BGR-22, the outer padding of BGR-23 and the nine new fixtures; reading comp.button-group.connected.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet. Lagging: ships an earlier revision, a row of Buttons and the retired icon-only kind with no orientation, placement flags or full width. The target: <ButtonGroup variant orientation fullWidth accessibilityLabel>{…}</ButtonGroup> with Button children, which it renders in the order of BGR-13 and BGR-14 (reordering the children themselves, so focus and the screen reader follow it, BGR-15) and which read a React context for their corners and minimum size (internal to the package). With a label it has role="group" and accessibilityLabel; without one it adds no accessibility element. Children of another type are reported in development builds (BGR-10). Also owed: separation (auto, divider, gap), the divider of BGR-22, the outer padding of BGR-23 and the nine new fixtures; reading comp.button-group.connected.separation as a typed choice needs the native token emitters to write a component's choice of kind as a typed enumeration, which they do not yet.

Tokens

Token Aliases Resolved (light)
--tanrei-comp-button-group-connected-divider-color sys.color.outline-variant #000000 · 8%
--tanrei-comp-button-group-connected-divider-inset sys.space.space100 8px
--tanrei-comp-button-group-connected-divider-on-fill-color sys.color.outline #848484
--tanrei-comp-button-group-connected-divider-thickness sys.border.hairline 1px
--tanrei-comp-button-group-connected-gap sys.space.space25 2px
--tanrei-comp-button-group-connected-horizontal-outer-inset sys.space.space50 4px
--tanrei-comp-button-group-connected-horizontal-outer-shape sys.shape.corner.full 9999px
--tanrei-comp-button-group-connected-inner-shape sys.shape.corner.small 6px
--tanrei-comp-button-group-connected-separation sys.behavior.separation 0
--tanrei-comp-button-group-connected-vertical-outer-shape sys.shape.corner.large 12px
--tanrei-comp-button-group-standard-gap — 16px

Fixtures

Canonical renderings. Every platform snapshots each fixture under the same id so the results can be compared.

Generated from specs/button-group.spec.json, tokens/comp/button-group.tokens.json, design/tokens/

Guidelines

When to use

Use a button group when a few buttons belong together: the end of a form (Cancel, Save draft, Publish), an editor’s formatting (Bold, Italic, Underline), a calendar’s Previous, Today and Next. The group settles their spacing, joins them when they are one set, and names them when that helps. Each button stays a button.

A row of actions for the current screen or selection, reached as one Tab stop with the arrow keys moving between them, is a toolbar. A main action joined to a menu of its variations is a button with menu: it looks like a connected group of two, but its second half always opens a menu.

Segmented button, button group, chips or tabs

These four all put a few related things in a row, so they are easy to confuse. They differ in what a press does and in how the keyboard moves through them.

Component What it is Selection Keyboard Use it for
Segmented button (the Material and cupertino presets; not in the Tanrei system) One control: two to five joined options that answer one question. Its own, by value: one option, or several. One Tab stop. The arrow keys move between options, and with single selection they also choose. A setting with a few fixed answers that applies at once: Day, Week, Month; left, center, right.
Button group A row or a column of separate buttons, spaced apart or joined into one shape. None. A toggle button in the group keeps its own on or off state. Every button is its own Tab stop. Related actions: Cancel, Save draft, Publish; Bold, Italic and Underline as independent toggles.
Chips Separate small controls in a row that wraps or scrolls. Each filter chip is on or off by itself. Every chip is its own Tab stop. Filters above a list, suggested replies, items the user entered.
Tabs Views of one subject, each with its own panel. One tab: the view on screen. One Tab stop. The arrow keys move and select. Switching which content is shown: Overview, Activity, Settings.

A quick test: if pressing it does something, it is a button group. If it changes the content below, it is tabs. If it sets one value, it is radio buttons, or a segmented button where the system ships one. Options that are many or independent are chips.

Choosing a variant

Variant What it does Use for
Standard (default) Spaces the buttons apart, far enough that each keeps its whole touch target between its neighbours. Each button keeps its own corners. Actions that sit together but stand on their own, such as a form’s Cancel and Save.
Connected Joins the buttons into one shape with rounded outer ends, by a divider or by a narrow gap (see Joined by a divider or by a gap). Every button is at least a target wide and stretched to the tallest. A closely related set that reads as one control: Bold, Italic, Underline; Previous, Today, Next.

In a connected group only the corners that face a neighbour change: square under a divider, tighter under a gap. The fill, outline, state layer, ripple and focus ring follow the joined shape, and the rounded outer ends of a joined row get a little more padding, so the content of the end buttons looks evenly set. A small icon-only button widens around its centred icon to the target width, because joined buttons cannot reach over each other.

A row or a column

Use a row by default. Use a column where the buttons would not fit side by side with their whole labels, such as the actions of a narrow sheet or a phone-width panel, or beside content that runs vertically, such as a map’s zoom controls. Mark the buttons that complete the task and the ones that back out of it, so the order stays the one people expect when a layout changes between a row and a column.

  • Give every button in a group the same size.
  • Spend emphasis as you would anywhere: one filled button at most, usually at the end.
  • Give the group a label when its buttons only make sense together, such as “Text style”. Leave it out for a row of actions that each say what they do.
  • Use one variant for the buttons of a connected group, so the joined shape reads as one.
  • Mark the button that completes the task confirming and the one that backs out of it dismissive, and let the group place them. Write them in any order; the group does not move them in your markup.
  • Leave separation at auto unless one group must differ, so that every joined control in the product is told apart the same way.

Don’t

  • Don’t use toggle buttons in a group for choices that exclude each other, such as left, center and right alignment. The group does not make its toggles exclusive. Use tabs where the choice changes the content below, radio buttons where it sets a value, or a segmented button where the system ships one.
  • Don’t put anything but Tanrei Buttons, labelled or icon-only, in a group: no fields, chips or menus. Debug builds report other content.
  • Don’t use the flags for tone. A destructive Delete that completes the task is confirming, and takes its look from its own destructive; a button with both flags is placed as if it had neither, and reported.
  • Don’t expect the row to wrap. A row that does not fit narrows its Buttons and wraps their labels. It never moves to a second line or scrolls.
  • Don’t mix sizes in one group. The group does not resize its buttons.

Accessibility

Role
group
Labeling
label, when given, names the group ("Text style"). Each item is named as it is anywhere: a Button by its label text, an icon-only Button by its label prop.
Minimum target
The hit area meets the minimum target size even when the visual size is smaller. minimum 32px · pointer 44px · spacing 8px · touch 48px

Keyboard

Keys Action
Tab / Shift+Tab Moves focus to each enabled item in turn in the placed order (BGR-15): start to end in a row, top to bottom in a column.
Enter / Space Activates the focused item, as the Button itself says (a toggle switches).

Notes

  • A button group is a layout of actions: each item is its own Tab stop with its own semantics. A row that should be one Tab stop with arrow keys is a toolbar; one choice among several is a single-selection control: tabs where it changes the content below, a radio group where it sets a value, or a segmented button in a system that ships one.
  • A toggle Button in a group keeps its own on/off state; the group does not make its toggles exclusive. One choice among several is a single-selection control: tabs where it changes the content below, a radio group where it sets a value, or a segmented button in a system that ships one.
  • confirming and dismissive are not announced: they decide where a Button is, and focus and reading follow that place, so a screen-reader user meets the actions in the same order a sighted user sees them.
  • The two flags describe the task, not the tone: in "Cancel · Delete" Delete is confirming. A destructive action is told apart by its own look (the Button's destructive) and its label, not by its place.
  • Compose has no group role: the group is a traversal group whose content description is label, and each item keeps its own role.
  • Text size: Buttons in a group grow as they do anywhere; a connected row stretches every item to the tallest, and a column makes every item as wide as the widest.
  • A divider is decoration: it is not exposed, takes no press and is no boundary a person must find. What tells two joined Buttons apart for assistive technology is that each is its own button with its own name; for sight, their content and the line or the gap between them.
  • A seam between a selected and an unselected toggle carries no line: the fill of the selected item ends there, at a contrast far above 3:1 against its neighbour, and that edge is the boundary. The selected state itself is exposed by the toggle (BTN-14), never by the line.
  • In the default system the outline of an outlined group and its rules are the hairline (the divider's color), far below 3:1 against the surface. That is within WCAG 1.4.11: each Button is identified by its label or its icon, not by its edge.

Generated from specs/button-group.spec.json, tokens/sys/measure.tokens.json

What the component handles

  • Every button keeps its own role, name, state and Tab stop. Tab moves through them in the placed order: start to end in a row, top to bottom in a column. The group takes no focus, handles no arrow keys and keeps no selection.
  • confirming and dismissive are not announced. They decide where a button is, and focus and reading follow that place.
  • A divider is decoration: it is hidden from assistive technology and takes no press. What tells two joined buttons apart is that each is its own button with its own name.
  • With a label, the group is exposed as a group named by it that contains its buttons. Without one, it adds nothing to the accessibility tree.
  • In a connected group every button is at least a target wide, and its hit area reaches above and below the row to the minimum target. In a standard group, a press on a button’s visible bounds always goes to that button, and a press in the gap goes to the nearer one.
  • The focus indicator follows each button’s shape, joined corners included.
  • At larger text sizes the buttons grow as they do anywhere. A connected row stretches every button to the tallest and a column makes every button as wide as the widest, so the joined shape stays even.
  • In the default system the outline of an outlined group and its dividers are the hairline, which is deliberately quiet: each button is identified by its label or its icon, not by its edge.
  • Debug builds report a group of fewer than two items, content that is not a Tanrei Button, a button with both flags, an unknown variant, orientation or separation, a full-width row whose buttons cannot share the width equally, and a row that still does not fit after its Buttons have narrowed.

Known platform gaps:

  • Web: a connected icon button is the pointer target wide under a fine pointer, such as a mouse, and the touch target wide under a coarse one. Where an item’s own shape is a pill, the corners it turns toward a neighbour are circular rather than eased by corner smoothing. Inside a group a Button’s label may wrap; outside one it stays on one line. The report of a row that still does not fit is checked when the group resizes or its children change, not when an item’s label changes.
  • SwiftUI has no group role. A labelled group is a container named by its label, holding its buttons. A Button whose title is a LocalizedStringKey, as a string literal is, can be narrowed below its longest word, which then breaks inside the word. The report of a row that still does not fit is an assertion failure in debug builds, so a layout that is too narrow for a moment, such as a container whose width animates, stops a debug app.
  • Compose has no group role. A labelled group is a traversal group whose content description is the label, and each button keeps its own role.
  • React Native: how iOS and Android divide a press in the gap between two buttons, the stretched buttons of a connected group at large text sizes, and whether VoiceOver and TalkBack announce the group’s name when focus enters it, have not been checked on a device.
  • React Native: a narrowed Button can shrink below its longest word, whose letters then break across lines, because React Native’s layout has no minimum content width for text.
  • In a standard group, hit areas meet at the middle of the gap. The default system’s gap (16) is wide enough that every icon button, the small one included, keeps its whole 48 touch target between its neighbours. A design system with a narrower gap narrows the targets between buttons.

What you need to do

  • Name every icon button. The group’s label does not name the buttons inside it.
  • Put the group where its buttons belong in the reading order. Assistive technology reaches them one by one, in the order they appear.
  • If a set of toggles must stay mutually exclusive, use a single-selection control: tabs where the choice changes the content below, radio buttons where it sets a value, or a segmented button where the system ships one. Each is announced as one choice; a row of toggle buttons is not.