|Kit

Button Select

Pick one value from a row of joined Buttons — the selected one flips to its own variant. Built on Ark UI's ToggleGroup machine, with label / description / error and native form submission.

ButtonSelect is a single-choice control made of real Buttons, joined like a ButtonGroup. Unselected items render in variant, and the selected one switches to selectedVariant. Every item goes through Button, so icons, per-item colors, sizes and the focus ring work as they do on a Button.

Use it instead of SegmentGroup when the options should look like buttons, such as a toolbar or a row of icons. Use it instead of a Select when the set is small enough to show every option. To pick several values, use ButtonMultiSelect.

import { ButtonSelect } from "@42-central/ui-react/button-select";

<ButtonSelect data={["Day", "Week", "Month"]} defaultValue="Week" />;

Playground

ButtonSelect

Loading preview

Props

Code

<ButtonSelect
  orientation="horizontal"
  size="md"
  color="brand"
/>

Items

data accepts bare strings, where the value is also the label, or { value, label?, startSlot?, endSlot?, disabled?, color?, "aria-label"?, payload? } objects. You can mix both. Each item also takes an optional testId, which defaults to `item-${value}`. See the Testing guide.

Variants

Two props set the look: variant for unselected items (default light) and selectedVariant for the selected one (default filled). Both accept any Button variant, so you can use a loud pairing for a primary choice or a quiet one for a toolbar.

Code

<ButtonSelect color="brand" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect color="brand" variant="outline" selectedVariant="filled" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect color="brand" variant="subtle" selectedVariant="light" data={['Day', 'Week', 'Month']} defaultValue="Week" />

Sizes

The five Button sizes, xs through xl.

Code

<ButtonSelect size="xs" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect size="sm" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect size="md" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect size="lg" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect size="xl" data={['Day', 'Week', 'Month']} defaultValue="Week" />

Colors

color sets the palette for every item. It is a data-color on the group, so every kit palette works.

Code

<ButtonSelect color="green" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect color="blue" data={['Day', 'Week', 'Month']} defaultValue="Week" />
<ButtonSelect color="purple" data={['Day', 'Week', 'Month']} defaultValue="Week" />

Per-item color

An item's own color overrides the group palette for that button, whether it is selected or not. This suits status pickers where each option has its own hue.

Icons

startSlot and endSlot pass through to Button.

Code

<ButtonSelect
  color="brand"
  defaultValue="week"
  data={[
    { value: 'day', label: 'Day', startSlot: <CalendarDays /> },
    { value: 'week', label: 'Week', startSlot: <CalendarRange /> },
    { value: 'month', label: 'Month', startSlot: <LayoutGrid /> },
  ]}
/>

For an icon-only item, put the icon in label and give the item an aria-label:

Code

<ButtonSelect
  size="sm"
  variant="subtle"
  selectedVariant="light"
  defaultValue="grid"
  data={[
    { value: 'list', label: <List />, 'aria-label': 'List view' },
    { value: 'grid', label: <LayoutGrid />, 'aria-label': 'Grid view' },
    { value: 'rows', label: <Rows3 />, 'aria-label': 'Rows view' },
  ]}
/>

Deselectable

By default the group behaves like a radio group: clicking the selected item leaves it selected. With deselectable, that click clears the selection and onChange receives null. The types match: onChange's parameter only includes null when deselectable is set.

value: "week"

Code

const [range, setRange] = useState<'day' | 'week' | 'month' | null>('week')

<ButtonSelect
  deselectable
  data={[{ value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }]}
  value={range}
  onChange={setRange}
/>

Custom rendering

Pass renderItem to render each item's content yourself. It receives the item, narrowed to your data's values and typed payload, along with its live { selected, disabled } state. The item keeps its button, variant and selection wiring. Define the function in a "use client" module.

"use client";

// item.payload is inferred from `data` — here { readonly count: number } | undefined
const renderCount = (item, { selected }) => (
  <span className="inline-flex items-center gap-2">
    {item.label}
    <span className={selected ? "bg-(--c-on-solid)/20" : "bg-(--c-soft)"}>
      {item.payload?.count}
    </span>
  </span>
);

ButtonSelect is generic over data through a const type parameter, like Select: value and onChange narrow to the literal value union.

Orientation

orientation="vertical" stacks the items in a column.

Label, description & error

ButtonSelect wraps itself in Field, so label, description, error and required work without extra setup and wire up the aria-* relationships. error (or invalid) draws a red ring around the group.

How far the chart zooms out.
Pick a range

Composition

For anything data can't express, build the group from its parts. ButtonSelect.Root holds the value and sets variant, selectedVariant, size and color once for every item. Each ButtonSelect.Item accepts the same props as a Button (startSlot, endSlot, color, testId…), except variant and size, which come from the root. Pass asChild to render your own variant-aware control, such as an ActionIcon, in place of the Button. See ButtonMultiSelect's toolbar.

Code

<ButtonSelect.Root aria-label="Text alignment" defaultValue="left" color="neutral">
  <ButtonSelect.Item value="left" startSlot={<AlignLeft />}>Left</ButtonSelect.Item>
  <ButtonSelect.Item value="center" startSlot={<AlignCenter />}>Center</ButtonSelect.Item>
  <ButtonSelect.Item value="right" startSlot={<AlignRight />} color="brand">Right</ButtonSelect.Item>
</ButtonSelect.Root>

The compound root has no Field wrapper or name. Label it with aria-label / aria-labelledby.

Forms

name renders a hidden input holding the selected value, so the value is sent with a native <form> submission and shows up in FormData. Nothing is submitted while the selection is empty.

Code

<form action={save}>
  <ButtonSelect name="range" label="Range" data={['Day', 'Week', 'Month']} defaultValue="Week" />
</form>

API

Prop

Type

ButtonSelect.Root accepts the same styling props plus value / defaultValue / onChange / deselectable, and ButtonSelect.Item takes value plus every Button prop except variant, size and loading. With asChild, it takes value, disabled and a single child control instead.

Accessibility

  • Built on Ark UI's ToggleGroup machine in single mode: the group is a role="radiogroup", and each item is a role="radio" with aria-checked.
  • Roving focus: Tab enters and leaves the group. ← / → (or ↑ / ↓ when vertical) and Home / End move focus, and Space / Enter selects. Unlike SegmentGroup, moving focus doesn't change the selection.
  • The label, description and error are wired to the group through the built-in Field.
  • Icon-only items need an aria-label.
  • In a right-to-left locale (from IntlProvider), the joined corners mirror and ← / → follow the visual order.

Changelog

v0.16.0
  • New ButtonSelect and ButtonMultiSelect: single- and multiple-choice rows of joined Buttons

    #252

On this page