---
title: Select
description: Selects let users choose one or more known values from a list inside a compact trigger and popup listbox.
---

import { meta as SelectMeta, Default, Multiple, Searchable, TriggerKindFocus } from '@/content/stories/select';
import Canvas from '@/components/component-docs/canvas';
import Demo from '@/components/component-docs/demo';

export const config = {
	dependencies: {
		components: [Canvas, Demo],
		scripts: [
			'../../../components/component-docs/demo.script.tsx',
			'../../../components/component-docs/canvas.script.tsx',
			'../../../components/component-docs/controls.script.tsx',
		],
	},
};


# Select

<p class="docs-lede">Selects let users choose one or more known values from a list inside a compact trigger and popup listbox. Use a select when the value must be one of the options; use a combobox when users also need to type and filter.</p>

## Try it

<Demo of={Default} meta={SelectMeta} />

## Mental model

`<rui-select>` is a behavior host: helpers place the trigger, value, and popup; the custom element owns open state, keyboard, and selection. Options live in an embedded `RuiListbox`. Named regions are Composition Helpers (`RuiSelectTrigger`, `RuiSelectClear`, …), not HTML slots.

The live `value` property is always a `string[]` (`[]` when nothing is selected). The HTML attribute is those tokens as a comma-separated string (`value="draft,published"`). JSX may pass a string or an array; a string is parsed as CSV. `rui-change` emits `{ value: string[] }`. Commas in the attribute are delimiters, not characters inside a token.

Not form-associated. Wrap in `RuiField` and read the value from `RuiForm` `onSubmit`. It does not appear in `new FormData(form)`.

## Usage

Pass `options` for the default composition, or supply children for an explicit layout.

```tsx
import { RuiSelect } from '@ecopages/radiant-ui/select';
import { RuiLabel } from '@ecopages/radiant-ui/label';

<RuiLabel>Animal</RuiLabel>
<RuiSelect
  value="cat"
  placeholder="Select an animal"
  options={[
    { value: 'cat', label: 'Cat' },
    { value: 'dog', label: 'Dog' },
  ]}
/>
```

The `options` API does not include a clear control. Add `RuiSelectClear` in a composed layout when the field should be resettable. Children replace the default close icon.

```tsx
import {
  RuiSelect,
  RuiSelectClear,
  RuiSelectControl,
  RuiSelectListbox,
  RuiSelectToggle,
  RuiSelectTrigger,
  RuiSelectValue,
} from '@ecopages/radiant-ui/select';
import { RuiListbox, RuiListboxOption } from '@ecopages/radiant-ui/listbox';
import { RuiIconX } from '@ecopages/radiant-ui/icons';

<RuiSelect value="cat" placeholder="Select an animal">
  <RuiSelectControl>
    <RuiSelectTrigger>
      <RuiSelectValue />
    </RuiSelectTrigger>
    <RuiSelectClear aria-label="Clear animal selection">
      <RuiIconX />
    </RuiSelectClear>
    <RuiSelectToggle />
  </RuiSelectControl>
  <RuiSelectListbox>
    <RuiListbox embedded>
      <RuiListboxOption value="cat">Cat</RuiListboxOption>
      <RuiListboxOption value="dog">Dog</RuiListboxOption>
    </RuiListbox>
  </RuiSelectListbox>
</RuiSelect>
```

Always set `embedded` on a listbox inside the popup so the select owns selection and keyboard.

## Custom markup

`<rui-select>` coordinates any light-DOM tree that matches its query contract. The `Rui*` helpers stamp these targets; they are not required.

```tsx
import '@ecopages/radiant-ui/select';
import '@ecopages/radiant-ui/listbox';

<rui-select value="cat" placeholder="Select an animal">
  <div data-ref="root" class="rui-select">
    <div class="rui-select__control">
      <div data-select-trigger tabindex="0">
        <span data-select-value></span>
      </div>
      <button type="button" data-select-toggle></button>
    </div>
    <div data-select-listbox hidden>
      <rui-listbox embedded>
        <div role="option" data-value="cat">Cat</div>
        <div role="option" data-value="dog">Dog</div>
      </rui-listbox>
    </div>
  </div>
</rui-select>
```

BEM classes are presentation-only. Nested `rui-listbox` must have `embedded`. For multi-select chips, place `rui-tag-group` inside `[data-select-value]`.

## Multiple selection

Set `selectionMode="multiple"`. The default `options` composition shows selected values as removable tags and adds a check indicator on each option. Composed layouts place a `RuiTagGroup` inside `RuiSelectValue`, and `RuiListboxOptionIndicator` inside an option to replace the check.

<Canvas of={Multiple} meta={SelectMeta} />

```tsx
<RuiSelect
  selectionMode="multiple"
  value={['cat', 'dog']}
  placeholder="Select animals"
  options={[
    { value: 'cat', label: 'Cat' },
    { value: 'dog', label: 'Dog' },
  ]}
/>
```

The popup stays open while options are toggled. Pass `shouldCloseOnSelect` to close after each choice.

## Searchable lists

Wrap the embedded listbox in `RuiAutocomplete` and add `RuiSelectSearch` when the collection is long. Opening the popup moves focus into the search field.

<Canvas of={Searchable} meta={SelectMeta} />

## Trigger kind

`triggerKind` controls what opens the listbox (same attribute name as `RuiCombobox`; select supports only `focus` and `manual`):

| Value | Opens when |
| --- | --- |
| `manual` (default) | Click, toggle, or arrow keys on the trigger |
| `focus` | The trigger receives focus (plus click, toggle, and arrow keys) |

<Canvas of={TriggerKindFocus} meta={SelectMeta} />

## Theming

Select surfaces map to **semantic surface roles**, never Tailwind palette steps. Override at the theme layer, not in component CSS.

| Part | CSS roles |
| --- | --- |
| Trigger (`rui-select__trigger`) | `on-background`; `border` on the control row |
| Control row (`rui-select__control`) | `border`, `background`, `rounded-control` |
| Popup (`rui-select__listbox`) | `popover` surface + `shadow-overlay` |
| Selected value / toggle | `on-surface` |

Geometry shares control tokens (`--size-control-*`, `--radius-control`, `--space-control-*`) with `RuiInput` and `RuiButton`.

Customization of icons is through Composition Helper **children**, not CSS variables: pass children to `RuiSelectToggle`, `RuiSelectClear`, and `RuiListboxOptionIndicator`.

## Accessibility

- The trigger exposes `role="combobox"`, `aria-expanded`, and `aria-haspopup="listbox"`.
- Multiple mode marks the popup listbox `aria-multiselectable="true"`.
- `RuiSelectClear` needs an accessible name (`aria-label="Clear selection"` by default). Clicking it clears the value and returns focus to the trigger.
- Provide a visible label. The placeholder is not a substitute.

## API

`RuiSelect` is a custom element (`<rui-select>`) wrapping a button trigger and an embedded `RuiListbox`. It is not form-associated.

### Attributes (`<rui-select>`)

| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | omitted | Comma-separated selected tokens. Empty selection removes the attribute. The JS property is `string[]`. |
| `label` | `string` | `''` | Accessible name when there is no associated `RuiLabel`. |
| `placeholder` | `string` | `''` | Shown when nothing is selected. |
| `disabled` | `boolean` | `false` | Disabled state. |
| `selection-mode` | `single` · `multiple` | `single` | Single or multi-select. |
| `should-close-on-select` | `boolean` | | Whether selecting closes the popup (defaults to `true` for single, `false` for multiple). |
| `trigger-kind` | `focus` · `manual` | `manual` | Controls what opens the listbox. |

### Light-DOM contract

| Target | Required | Host writes | Author owns |
| --- | --- | --- | --- |
| `[data-ref="root"]` | yes | — | shell for popover anchoring |
| `[data-select-trigger]` | yes | `role`, `aria-expanded`, `aria-controls`, `aria-haspopup`, `id`, `tabIndex`, `aria-disabled` | combobox `div` (not a native `<button>` when chips with remove controls are inside) |
| `[data-select-value]` | yes | `data-placeholder`, optional `[data-select-placeholder]` | content / `rui-tag-group` |
| `[data-select-listbox]` | yes | `hidden` | popup shell |
| `[role="option"]` | yes (in listbox) | `aria-selected`, option `id` | `data-value`, label text |
| `[data-select-toggle]` | no | `aria-expanded`, `disabled` | the button |
| `[data-select-clear]` | no | `hidden`, `disabled` | the button |
| `[data-autocomplete-input]` | no | `role`, `aria-*` when popup open | search field |

Do not set `role`, `aria-expanded`, or `tabIndex` on the trigger. Nested hosts: `rui-listbox` (`embedded`), `rui-tag-group` in `[data-select-value]`, optional `rui-autocomplete` in the listbox.

### Events

| Event | Detail | Description |
| --- | --- | --- |
| `rui-change` | `{ value: string[] }` | Emitted after a selection changes. |

### View helpers

| Component | Target stamped | Notes |
| --- | --- | --- |
| `RuiSelect` | `<rui-select>` + `[data-ref="root"]` | Accepts `options` or children |
| `RuiSelectControl` | — | Trigger row wrapper |
| `RuiSelectTrigger` | `[data-select-trigger]` | Combobox `div`; place `RuiSelectValue` inside |
| `RuiSelectValue` | `[data-select-value]` | Tags via `RuiTagGroup` in multiple mode |
| `RuiSelectClear` | `[data-select-clear]` | Hidden when empty |
| `RuiSelectToggle` | `[data-select-toggle]` | Children replace chevron |
| `RuiSelectListbox` | `[data-select-listbox]` | Place embedded `RuiListbox` inside |
| `RuiSelectSearch` | `[data-autocomplete-input]` | Inside `RuiAutocomplete` |

### CSS classes

| Class | Description |
| --- | --- |
| `.rui-select__control` | Trigger row: bordered control-height surface. |
| `.rui-select__trigger` | Combobox surface (`role="combobox"`). |
| `.rui-select__value` | Selected value / placeholder text. |
| `.rui-select__listbox` | Popup shell (adds `rui-popover rui-popover--listbox rui-floating`). |
| `.rui-select__search` | Filtering input inside the popup. |

### Theme roles

| Part | CSS variables consumed |
| --- | --- |
| Control row | `--border`, `--background`, `--radius-control` |
| Trigger text | `--on-background` |
| Value / toggle | `--on-surface` |
| Popup | `--shadow-overlay` (via `rui-popover`) |
| Geometry | `--size-control-*`, `--space-control-*` |
