Button group
Button group is a segmented control for 2–6 related options or actions. Use it to switch a view or range in place, or to keep a few equal actions together; use a menu for more.
Beta until items get a designed focus state (C10), the text-only item heights are confirmed in Figma (C11), and 2xs icon-only items meet the 24 × 24 target size (C26).
Preview
Change the controls to try every supported combination. The preview renders the real component, every item works, and the code updates with it.
Lesson view: List.
import { ButtonGroup, ButtonGroupItem } from '@nuton/ui'
const [selected, setSelected] = useState('List')
<ButtonGroup aria-label="Lesson view">
<ButtonGroupItem pressed={selected === 'List'} onClick={() => setSelected('List')}>List</ButtonGroupItem>
<ButtonGroupItem pressed={selected === 'Grid'} onClick={() => setSelected('Grid')}>Grid</ButtonGroupItem>
<ButtonGroupItem pressed={selected === 'Board'} onClick={() => setSelected('Board')}>Board</ButtonGroupItem>
</ButtonGroup>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactPeer dependencies: react and react-dom 18 or later. @remixicon/react supplies the icons you pass to items (Remix Icon is Nuton's icon set, D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)Item labels use --nu-font-body (Inter), the same font token as Button. If you haven't mapped it yet, follow the font setup on the Button page. No provider is needed: ButtonGroup passes its size to its items itself.
Usage
import { useState } from 'react'
import { ButtonGroup, ButtonGroupItem } from '@nuton/ui'
export function ProgressRange() {
const [range, setRange] = useState('Week')
return (
<ButtonGroup aria-label="Progress range" size="xs">
{['Day', 'Week', 'Month'].map((r) => (
<ButtonGroupItem key={r} pressed={range === r} onClick={() => setRange(r)}>{r}</ButtonGroupItem>
))}
</ButtonGroup>
)
}- The default is sm, smooth: a 36px pill. Every
ButtonGroupItemmust sit directly inside aButtonGroup. - Always name the group with
aria-labeloraria-labelledby. It rendersrole="group"and warns in development without a name. - For options (one is chosen), pass
pressedto every item and update it on click. For plain actions, leavepressedundefined. - Keep to 2–6 items with short, parallel labels in sentence case: “Day”, “Week”, “Month”.
typedefaults to"button", so items never submit a surrounding form by accident.
Variants and sizes
A button group is one of two patterns, set by how you use pressed. Size and corners apply to the whole group; items take their size from it.
Single select or actions
| Pattern | How | Announced as | Nuton example |
|---|---|---|---|
| Single select | Every item gets pressed; exactly one is true | Toggle buttons, “pressed” / “not pressed” | Progress range: Day · Week · Month |
| Independent toggles | Each item has its own pressed boolean | Toggle buttons | Text formatting: Bold · Italic · Underline |
| Actions | pressed left undefined, so no aria-pressed | Plain buttons | Flashcards: Previous card · Next card |
Sizes
sm · 36pxxs · 32px2xs · 24pxconst ranges = ['Day', 'Week', 'Month']
const [range, setRange] = useState('Week')
{(['sm', 'xs', '2xs'] as const).map((size) => (
<ButtonGroup key={size} size={size} aria-label={`Progress range, ${size}`}>
{ranges.map((r) => (
<ButtonGroupItem key={r} pressed={range === r} onClick={() => setRange(r)}>{r}</ButtonGroupItem>
))}
</ButtonGroup>
))}| Size | Height | Text inset | Icon gap | Icon | Icon-only item | Type |
|---|---|---|---|---|---|---|
sm | 36 | 16 | 8 | 20 | 34 × 34 | Label/Small (14/20) |
xs | 32 | 14 | 6 | 20 | 30 × 30 | Label/Small (14/20) |
2xs | 24 | 12 | 4 | 16 | 22 × 22 | Label/X-Small (12) on a 16px line |
Heights are the outer group, including its 1px border; items sit inside that border and are separated by 1px Stroke/Soft-200 dividers, so an icon-only item is 2px smaller than the group height. The code uses Figma's labelled sizes (36/32/24), which match its icon variants. Figma's text-only items measure 32/28/24 under the same labels; that discrepancy is open (C11, FC-09).
Gap: 2xs icon-only items are below the 24 × 24 target size (C26)
At 2xs, an icon-only item is 22 × 22 and sits edge to edge with its neighbours, so it fails WCAG 2.2 AA target size (2.5.8; axe target-size). The component CSS is unchanged until design decides between larger 2xs targets and spacing between them. Until then, use xs or larger for icon-only groups.
Corners
smoothroundedsharpconst ranges = ['Day', 'Week', 'Month']
const [range, setRange] = useState('Week')
{(['smooth', 'rounded', 'sharp'] as const).map((corners) => (
<ButtonGroup key={corners} size="xs" corners={corners} aria-label={`Progress range, ${corners}`}>
{ranges.map((r) => (
<ButtonGroupItem key={r} pressed={range === r} onClick={() => setRange(r)}>{r}</ButtonGroupItem>
))}
</ButtonGroup>
))}Radius tokens: smooth --nu-radius-full, rounded --nu-radius-8 (--nu-radius-6 at 2xs), sharp --nu-radius-0. Figma calls this property Type. Match the corner style of the buttons around the group.
Quantity
Figma defines groups of 2 to 6 items (Quantity 02–06). The component doesn't enforce a count; it renders the items you pass.
const terms = ['Day', 'Week', 'Month', 'Term', 'Year', 'All']
{[2, 3, 4, 5, 6].map((count) => (
<ButtonGroup key={count} size="xs" aria-label={`Progress range, ${count} options`}>
{terms.slice(0, count).map((t) => (
<ButtonGroupItem key={t} pressed={selected[count] === t} onClick={() => select(count, t)}>{t}</ButtonGroupItem>
))}
</ButtonGroup>
))}Icons
<ButtonGroup size="sm" aria-label="Lesson view">
<ButtonGroupItem leadingIcon={<RiListUnordered />} pressed={view === 'list'} onClick={() => setView('list')}>List</ButtonGroupItem>
<ButtonGroupItem leadingIcon={<RiLayoutGridLine />} pressed={view === 'grid'} onClick={() => setView('grid')}>Grid</ButtonGroupItem>
</ButtonGroup>
<ButtonGroup size="sm" aria-label="Text formatting">
<ButtonGroupItem iconOnly aria-label="Bold" pressed={marks.bold} onClick={() => toggle('bold')}><RiBold /></ButtonGroupItem>
<ButtonGroupItem iconOnly aria-label="Italic" pressed={marks.italic} onClick={() => toggle('italic')}><RiItalic /></ButtonGroupItem>
<ButtonGroupItem iconOnly aria-label="Underline" pressed={marks.underline} onClick={() => toggle('underline')}><RiUnderline /></ButtonGroupItem>
</ButtonGroup>leadingIcon and trailingIcon sit beside the label (see the flashcard example below for a trailing icon). With iconOnly, children is the icon and the item becomes square; both icon props are then ignored. Icons are rendered aria-hidden.
States and interactions
States belong to items. Every group below works: click an item to change the selection.
pressed={false}.@media (hover: hover)) and never on disabled items.pressed sets aria-pressed="true": Bg/Weak-50 fill and Text/Strong-950 label.disabled on one item: Bg/Weak-50 fill, Text/Disabled-500 label. It leaves the tab order.State tokens
Button group has no component-level colour tokens: items read the semantic tokens below directly. Change them only through the theme, never per group.
| State | Background | Label | Selector |
|---|---|---|---|
| Default | --nu-bg-white | --nu-text-sub-600 | .nu-button-group__item |
| Hover | --nu-bg-weak-50 | --nu-text-sub-600 | :not(:disabled):hover, hover-capable pointers only |
| Pressed | --nu-bg-weak-50 | --nu-text-strong-950 | [aria-pressed='true'] |
| Disabled | --nu-bg-weak-50 | --nu-text-disabled-500 | :disabled |
| Border and dividers | --nu-stroke-soft-200, 1px | .nu-button-group, item border-inline-start | |
Composition examples
Progress range
A single-select group that changes the period a progress summary covers. The result is announced through a status region.
Note formatting
Independent toggles: each icon-only item turns its own style on or off, so any combination can be pressed.
Flashcard actions
A plain action group: no pressed, so the items are ordinary buttons. Previous and next move through the deck in place, so they're actions, not links.
Semantics and navigation
- Button group is a set of buttons in a
role="group", not tabs or a radio group. Each item is its own tab stop and arrow keys don't move between items. - If choosing an option swaps a whole panel of content, the tabs pattern fits better. If the choice is part of a form that gets submitted, use a select or radio inputs.
- Items always render
<button>. If an option goes to another page, use links, for example Link button. - The group doesn't wrap. On narrow screens, keep labels short, use fewer items, or move to a Dropdown menu.
API reference
ButtonGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'xs' | '2xs' | 'sm' | Height 36 / 32 / 24px (Figma Small / X-Small / 2X-Small). Items inherit it. |
corners | 'smooth' | 'rounded' | 'sharp' | 'smooth' | Pill, 8px (6px at 2xs), or square (Figma Type). |
aria-label | string | — | Names the group for assistive technology. Needed unless you pass aria-labelledby (development warning otherwise). |
childrenrequired | ReactNode | — | The ButtonGroupItem elements, 2–6 of them. |
ref | Ref<HTMLDivElement> | — | Forwarded to the group's <div>. |
…rest | HTMLAttributes<HTMLDivElement> | — | Any other native div attribute (aria-labelledby, id, className…). The group sets role="group"; attributes you pass are applied after it. |
ButtonGroupItem props
| Prop | Type | Default | Description |
|---|---|---|---|
pressed | boolean | — | Selected state (Figma State=Active). Sets aria-pressed. Leave undefined for plain actions, so no aria-pressed is rendered. |
leadingIcon | ReactNode | — | Icon before the label. Rendered aria-hidden. Ignored when iconOnly. |
trailingIcon | ReactNode | — | Icon after the label. Rendered aria-hidden. Ignored when iconOnly. |
iconOnly | boolean | false | Square item showing children as the icon. Needs aria-label (development warning otherwise). |
type | 'button' | 'submit' | 'reset' | 'button' | Native type. Defaults to button so it never submits by accident. |
children | ReactNode | — | The label, or the icon when iconOnly. |
ref | Ref<HTMLButtonElement> | — | Forwarded to the native button. |
…rest | Omit<ButtonHTMLAttributes, 'type'> | — | Any other native button attribute (onClick, disabled, aria-label, name…). disabled is the Figma Disabled state. |
Styling hooks
The group renders .nu-button-group with data-size and data-corners; items render .nu-button-group__item with data-icon-only and aria-pressed, and icons sit in .nu-button-group__icon. The size sets internal spacing variables (--nu-bg-item-pad-block, --nu-bg-item-pad-inline, --nu-bg-item-gap). Use props rather than styling these; they aren't a supported override point.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the next item. Every enabled item is its own tab stop; disabled items are skipped. |
| Shift + Tab | Moves focus to the previous item. |
| Enter / Space | Activates the focused item (native behaviour). In a single-select group this changes the pressed item. |
Arrow keys don't move between items; there is no roving focus.
Screen readers
The group has role group, named by its aria-label. Each item is a button named by its label, or by aria-label when icon-only. With pressed, items are toggle buttons announced as pressed or not pressed. Icons are hidden.
Focus
Figma defines no focus state for items, so the browser's focus outline is kept and the focused item is raised above its neighbours (C10). Pressing an item doesn't move focus.
Contrast and colour
Default labels Sub-600 on white 6.69:1; hover Sub-600 on Weak-50 6.24:1; pressed Strong-950 on Weak-50 16.73:1. Disabled labels (Disabled-500 on Weak-50, 3.95:1) stay readable (C8). The pressed fill against white is 1.07:1, so the darker label is the main visual cue; aria-pressed carries the state for assistive technology.
Touch and pointer
Text items are 36, 32 and 24px tall. Icon-only items are 34, 30 and 22px square; 2xs falls below the 24 × 24 minimum (WCAG 2.5.8, C26). Hover styles apply only on hover-capable pointers.
You must: name every group with aria-label or aria-labelledby; give icon-only items an aria-label; set pressed on every item of a single-select group and keep exactly one true; avoid 2xs for icon-only items until C26 is resolved.
Figma mapping
| Figma property | Figma values | Code |
|---|---|---|
| Type | Rounded · Smooth · Sharp | corners (default smooth) |
| Size | Small (36) · X-Small (32) · 2X-Small (24) | size: sm · xs · 2xs (text-only items measure 32/28/24 in Figma, C11) |
| Quantity | 02–06 | Number of ButtonGroupItem children |
| Item State | Default · Hover · Active · Disabled | Hover is interactive; Active = pressed (aria-pressed); disabled. No focus state (C10) |
| Item Only Icon · Left / Right Icon | Off · On | iconOnly, leadingIcon, trailingIcon |
Guidelines and related components
Use Button group for
- Switching a view or range in place: list or grid, day, week or month.
- A few independent toggles that belong together, such as text formatting.
- 2–6 closely related actions of equal weight, such as previous and next card.
Don't use Button group for
- More than 6 options, or long labels. Use a Dropdown menu or a select.
- Actions with different importance, such as Cancel and Save. Use separate Buttons.
- Going to other pages. Use Link button or links.
Do and don't
Common mistakes
- Forgetting the group's
aria-label, so screen readers announce an unnamed group. - Setting
pressedonly on the selected item. The others render noaria-pressedand read as plain buttons; passfalseto them. - Using
pressedon action items (Undo, Share). Actions aren't on or off; leave it undefined. - Rendering
ButtonGroupItemoutside aButtonGroup: it gets no size or border styling and warns in development. - Navigating from
onClickinstead of using links.