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
  • Accessibility
  • Figma Button Group 8:5823 · Button Group Items 8:5899
  • packages/ui/src/components/ButtonGroup

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.

tsx
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

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer 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

tsx
// 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

tsx
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 ButtonGroupItem must sit directly inside a ButtonGroup.
  • Always name the group with aria-label or aria-labelledby. It renders role="group" and warns in development without a name.
  • For options (one is chosen), pass pressed to every item and update it on click. For plain actions, leave pressed undefined.
  • Keep to 2–6 items with short, parallel labels in sentence case: “Day”, “Week”, “Month”.
  • type defaults 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

PatternHowAnnounced asNuton example
Single selectEvery item gets pressed; exactly one is trueToggle buttons, “pressed” / “not pressed”Progress range: Day · Week · Month
Independent togglesEach item has its own pressed booleanToggle buttonsText formatting: Bold · Italic · Underline
Actionspressed left undefined, so no aria-pressedPlain buttonsFlashcards: Previous card · Next card

Sizes

sm · 36px
xs · 32px
2xs · 24px
SizeHeightText insetIcon gapIconIcon-only itemType
sm361682034 × 34Label/Small (14/20)
xs321462030 × 30Label/Small (14/20)
2xs241241622 × 22Label/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

smooth
rounded
sharp

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.

Icons

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.

DefaultWhite fill, Text/Sub-600 label. Here every item is pressed={false}.
HoverInteract to see: Bg/Weak-50 fill. Applies only on devices with hover (@media (hover: hover)) and never on disabled items.
Pressed (Figma Active)pressed sets aria-pressed="true": Bg/Weak-50 fill and Text/Strong-950 label.
DisabledNative disabled on one item: Bg/Weak-50 fill, Text/Disabled-500 label. It leaves the tab order.
FocusTab into the group to see it. Figma defines no focus state for items, so the browser's focus outline is kept; a designed treatment is needed for Stable (C10).

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.

StateBackgroundLabelSelector
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.

Study time in Biology 101: 4 h 20 min this week

Note formatting

Independent toggles: each icon-only item turns its own style on or off, so any combination can be pressed.

Photosynthesis turns light into chemical energy.

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.

Card 3 of 10

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

ButtonGroup props
PropTypeDefaultDescription
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-labelstring—Names the group for assistive technology. Needed unless you pass aria-labelledby (development warning otherwise).
childrenrequiredReactNode—The ButtonGroupItem elements, 2–6 of them.
refRef<HTMLDivElement>—Forwarded to the group's <div>.
…restHTMLAttributes<HTMLDivElement>—Any other native div attribute (aria-labelledby, id, className…). The group sets role="group"; attributes you pass are applied after it.

ButtonGroupItem props

ButtonGroupItem props
PropTypeDefaultDescription
pressedboolean—Selected state (Figma State=Active). Sets aria-pressed. Leave undefined for plain actions, so no aria-pressed is rendered.
leadingIconReactNode—Icon before the label. Rendered aria-hidden. Ignored when iconOnly.
trailingIconReactNode—Icon after the label. Rendered aria-hidden. Ignored when iconOnly.
iconOnlybooleanfalseSquare 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.
childrenReactNode—The label, or the icon when iconOnly.
refRef<HTMLButtonElement>—Forwarded to the native button.
…restOmit<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

KeyBehaviour
TabMoves focus to the next item. Every enabled item is its own tab stop; disabled items are skipped.
Shift + TabMoves focus to the previous item.
Enter / SpaceActivates 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 propertyFigma valuesCode
TypeRounded · Smooth · Sharpcorners (default smooth)
SizeSmall (36) · X-Small (32) · 2X-Small (24)size: sm · xs · 2xs (text-only items measure 32/28/24 in Figma, C11)
Quantity02–06Number of ButtonGroupItem children
Item StateDefault · Hover · Active · DisabledHover is interactive; Active = pressed (aria-pressed); disabled. No focus state (C10)
Item Only Icon · Left / Right IconOff · OniconOnly, 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

DoShow which option is selected with pressed. aria-pressed tells screen readers the current choice, and the item is styled to match.
Don'tDon't style a choice with className alone. Without pressed, the selection is invisible to assistive technology and has no matching style.
DoKeep groups to a few short, parallel options. Everything stays visible and fits on a phone.
Don'tDon't mix unrelated actions in one group. A group implies the items are alike; a destructive action hides among safe ones.
DoUse xs or larger for icon-only groups. Each item stays at least 30 × 30, above the 24 × 24 minimum.
Don'tDon't use 2xs for icon-only items. At 22 × 22 they fail the WCAG 2.2 target size (C26).
DoPut overflow actions in a menu. A trailing More button keeps the group short.
Don'tDon't squeeze in every option. The group doesn't wrap, so it overflows on small screens.

Common mistakes

  • Forgetting the group's aria-label, so screen readers announce an unnamed group.
  • Setting pressed only on the selected item. The others render no aria-pressed and read as plain buttons; pass false to them.
  • Using pressed on action items (Undo, Share). Actions aren't on or off; leave it undefined.
  • Rendering ButtonGroupItem outside a ButtonGroup: it gets no size or border styling and warns in development.
  • Navigating from onClick instead of using links.