# Button Select (/docs/button-select)





`ButtonSelect` is a single-choice control made of real [`Button`](/docs/button)s, joined like a [`ButtonGroup`](/docs/button-group). 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`](/docs/segment-group) when the options should look like buttons, such as a toolbar or a row of icons. Use it instead of a [`Select`](/docs/select) when the set is small enough to show every option. To pick several values, use [`ButtonMultiSelect`](/docs/button-multi-select).

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

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

## Playground [#playground]

<story.Playground />

## Items [#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](/docs/testing) guide.

<story.Preview variant="Disabled item" />

## Variants [#variants]

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

<Preview align="center" code="&#x22;<ButtonSelect color=\&#x22;brand\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect color=\&#x22;brand\&#x22; variant=\&#x22;outline\&#x22; selectedVariant=\&#x22;filled\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect color=\&#x22;brand\&#x22; variant=\&#x22;subtle\&#x22; selectedVariant=\&#x22;light\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />&#x22;">
  <div className="flex flex-col items-start gap-3">
    <ButtonSelect color="brand" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect color="brand" variant="outline" selectedVariant="filled" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect color="brand" variant="subtle" selectedVariant="light" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />
  </div>
</Preview>

## Sizes [#sizes]

The five `Button` sizes, `xs` through `xl`.

<Preview align="center" code="&#x22;<ButtonSelect size=\&#x22;xs\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect size=\&#x22;sm\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect size=\&#x22;md\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect size=\&#x22;lg\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect size=\&#x22;xl\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />&#x22;">
  <div className="flex flex-col items-center gap-3">
    <ButtonSelect size="xs" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect size="sm" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect size="md" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect size="lg" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect size="xl" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />
  </div>
</Preview>

## Colors [#colors]

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

<Preview align="center" code="&#x22;<ButtonSelect color=\&#x22;green\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect color=\&#x22;blue\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n<ButtonSelect color=\&#x22;purple\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />&#x22;">
  <div className="flex flex-col items-start gap-3">
    <ButtonSelect color="green" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect color="blue" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />

    <ButtonSelect color="purple" data="[&#x22;Day&#x22;, &#x22;Week&#x22;, &#x22;Month&#x22;]" defaultValue="Week" />
  </div>
</Preview>

### Per-item color [#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.

<story.Preview variant="Per-item color" />

## Icons [#icons]

`startSlot` and `endSlot` pass through to `Button`.

<Preview align="center" code="&#x22;<ButtonSelect\n  color=\&#x22;brand\&#x22;\n  defaultValue=\&#x22;week\&#x22;\n  data={[\n    { value: 'day', label: 'Day', startSlot: <CalendarDays /> },\n    { value: 'week', label: 'Week', startSlot: <CalendarRange /> },\n    { value: 'month', label: 'Month', startSlot: <LayoutGrid /> },\n  ]}\n/>&#x22;">
  <IconSlotsDemo />
</Preview>

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

<Preview align="center" code="&#x22;<ButtonSelect\n  size=\&#x22;sm\&#x22;\n  variant=\&#x22;subtle\&#x22;\n  selectedVariant=\&#x22;light\&#x22;\n  defaultValue=\&#x22;grid\&#x22;\n  data={[\n    { value: 'list', label: <List />, 'aria-label': 'List view' },\n    { value: 'grid', label: <LayoutGrid />, 'aria-label': 'Grid view' },\n    { value: 'rows', label: <Rows3 />, 'aria-label': 'Rows view' },\n  ]}\n/>&#x22;">
  <IconOnlyDemo />
</Preview>

## Deselectable [#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.

<Preview align="center" code="&#x22;const [range, setRange] = useState<'day' | 'week' | 'month' | null>('week')\n\n<ButtonSelect\n  deselectable\n  data={[{ value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }]}\n  value={range}\n  onChange={setRange}\n/>&#x22;">
  <ControlledDemo />
</Preview>

## Custom rendering [#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.

<story.Preview variant="Custom rendering" />

```tsx
"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`](/docs/select#typed-payload): `value` and `onChange` narrow to the literal value union.

## Orientation [#orientation]

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

<story.Preview variant="Vertical" />

## Label, description & error [#label-description--error]

`ButtonSelect` wraps itself in [`Field`](/docs/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.

<story.Preview variant="With label" />

<story.Preview variant="Error" />

## Composition [#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`](/docs/action-icon), in place of the `Button`. See [ButtonMultiSelect's toolbar](/docs/button-multi-select#composition).

<Preview align="center" code="&#x22;<ButtonSelect.Root aria-label=\&#x22;Text alignment\&#x22; defaultValue=\&#x22;left\&#x22; color=\&#x22;neutral\&#x22;>\n  <ButtonSelect.Item value=\&#x22;left\&#x22; startSlot={<AlignLeft />}>Left</ButtonSelect.Item>\n  <ButtonSelect.Item value=\&#x22;center\&#x22; startSlot={<AlignCenter />}>Center</ButtonSelect.Item>\n  <ButtonSelect.Item value=\&#x22;right\&#x22; startSlot={<AlignRight />} color=\&#x22;brand\&#x22;>Right</ButtonSelect.Item>\n</ButtonSelect.Root>&#x22;">
  <CompoundDemo />
</Preview>

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

## Forms [#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.

<Preview align="center" code="&#x22;<form action={save}>\n  <ButtonSelect name=\&#x22;range\&#x22; label=\&#x22;Range\&#x22; data={['Day', 'Week', 'Month']} defaultValue=\&#x22;Week\&#x22; />\n</form>&#x22;">
  <FormDemo />
</Preview>

## API [#api]

<TypeTable
  type="{
  data: {
    type: &#x22;string[] | { value, label?, startSlot?, endSlot?, disabled?, color?, 'aria-label'?, payload?, testId? }[]&#x22;,
    description: &#x22;Items. Bare strings (value === label), or objects.&#x22;,
  },
  value: { type: &#x22;string | null&#x22;, description: &#x22;Controlled value.&#x22; },
  defaultValue: { type: &#x22;string | null&#x22;, description: &#x22;Uncontrolled initial value.&#x22; },
  onChange: {
    type: &#x22;(value) => void&#x22;,
    description:
      &#x22;Fires with the selected value, narrowed to the data's value union. Only receives null when deselectable.&#x22;,
  },
  deselectable: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Clicking the selected item clears the selection.&#x22;,
  },
  renderItem: {
    type: &#x22;(item, state) => ReactNode&#x22;,
    description:
      'Render each item\'s content. item is { value, label, disabled, color, payload }; state is { selected, disabled }. Define it in a &#x22;use client&#x22; module.',
  },
  variant: {
    type: '&#x22;filled&#x22; | &#x22;light&#x22; | &#x22;outline&#x22; | &#x22;subtle&#x22;',
    default: '&#x22;light&#x22;',
    description: &#x22;Button variant of the unselected items.&#x22;,
  },
  selectedVariant: {
    type: '&#x22;filled&#x22; | &#x22;light&#x22; | &#x22;outline&#x22; | &#x22;subtle&#x22;',
    default: '&#x22;filled&#x22;',
    description: &#x22;Button variant of the selected item.&#x22;,
  },
  size: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;md&#x22;',
    description: &#x22;Button size of every item.&#x22;,
  },
  color: { type: &#x22;Color&#x22;, description: &#x22;Palette of every item; an item's own color overrides it.&#x22; },
  orientation: {
    type: '&#x22;horizontal&#x22; | &#x22;vertical&#x22;',
    default: '&#x22;horizontal&#x22;',
    description: &#x22;Lay the items in a row or a column.&#x22;,
  },
  invalid: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;Red invalid state, OR'd with error.&#x22; },
  disabled: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;Disable every item.&#x22; },
  label: { type: &#x22;ReactNode&#x22;, description: &#x22;Field label.&#x22; },
  description: { type: &#x22;ReactNode&#x22;, description: &#x22;Field helper text.&#x22; },
  error: { type: &#x22;ReactNode&#x22;, description: &#x22;Field error; sets the invalid state.&#x22; },
  required: { type: &#x22;boolean&#x22;, default: &#x22;false&#x22;, description: &#x22;Required flag + asterisk.&#x22; },
  name: { type: &#x22;string&#x22;, description: &#x22;Name of the hidden input (form submission).&#x22; },
  classNames: { type: &#x22;{ field?, label?, root?, item? }&#x22;, description: &#x22;Per-part class overrides.&#x22; },
  testId: {
    type: &#x22;string&#x22;,
    description: &#x22;data-testid on the group root. Items take their own testId (see Items).&#x22;,
  },
}"
/>

`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 [#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`](/docs/intl)), the joined corners mirror and `←` / `→` follow the visual order.
