---
title: Listbox
description: Listboxes present a scrollable set of options with single or multiple selection. They are the option surface for select, combobox, and autocomplete popups.
---

import { meta as ListboxMeta, Default, Multiple } from '@/content/stories/listbox';
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',
		],
	},
};


# Listbox

<p class="docs-lede">Listboxes present a scrollable set of options with single or multiple selection. Standalone, they are an inline chooser. Inside select and combobox, they are the embedded option surface for the popup.</p>

## Try it

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

## Mental model

`<rui-listbox>` is a behavior host: `RuiListboxOption` children stay in parent JSX. The custom element coordinates `aria-selected`, roving tabindex, and `rui-change`. When `embedded` is set, the parent (select or combobox) owns selection and keyboard; the listbox only paints options.

The live `value` property is always a `string[]` (`[]` when nothing is selected). The HTML attribute is those tokens as a comma-separated string. 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 form-associated. A standalone listbox wrapped in `RuiField` is available to `RuiForm` `onSubmit` only. An `embedded` listbox is the parent select or combobox's option surface, not a field control.

## Usage

Add `RuiListboxOption` children with unique `value` props, or pass `options` for the default composition.

```tsx
import { RuiListbox, RuiListboxOption } from '@ecopages/radiant-ui/listbox';

<RuiListbox value="cat" label="Animal">
  <RuiListboxOption value="cat">Cat</RuiListboxOption>
  <RuiListboxOption value="dog">Dog</RuiListboxOption>
</RuiListbox>
```

```tsx
<RuiListbox
  value="cat"
  label="Animal"
  options={[
    { value: 'cat', label: 'Cat' },
    { value: 'dog', label: 'Dog' },
  ]}
/>
```

## Custom markup

`<rui-listbox>` 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/listbox';

<rui-listbox value="cat" label="Animal">
  <div role="listbox" class="rui-listbox rui-listbox--bordered">
    <div role="option" data-value="cat" class="rui-listbox__option">Cat</div>
    <div role="option" data-value="dog" class="rui-listbox__option">Dog</div>
  </div>
</rui-listbox>
```

BEM classes are presentation-only. Do not set `aria-selected` or `tabIndex` on options — the host owns those.

## Embedded listboxes

Set `embedded` when the listbox lives inside a select or combobox popup. The parent then owns selection; border chrome is omitted so the popup can provide it. An embedded listbox is not a field control: place the parent select or combobox inside `RuiField`. `bordered` overrides that default.

## Selection indicators

The `options` API adds the default check SVG **only in multiple mode**. Single-select uses the selected-option background alone. For a composed option in either mode, place `RuiListboxOptionIndicator` as a child and pass children to replace the check. Visibility follows `aria-selected`; the indicator is decorative.

```tsx
import { RuiListboxOption, RuiListboxOptionIndicator } from '@ecopages/radiant-ui/listbox';
import { RuiIconCheck } from '@ecopages/radiant-ui/icons';

<RuiListboxOption value="cat" label="Cat">
  Cat
  <RuiListboxOptionIndicator>
    <RuiIconCheck />
  </RuiListboxOptionIndicator>
</RuiListboxOption>
```

Set `label` when the option contains decorative nodes (emoji, icons). Without it, the accessible name is the option's text minus the indicator subtree.

## Multiple selection

`selectionMode="multiple"` exposes `aria-multiselectable="true"`. Click or Space/Enter toggles the focused option. Arrow keys move focus without changing the selection.

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

```tsx
<RuiListbox
  selectionMode="multiple"
  value={['apple', 'banana']}
  label="Favorite fruit"
  options={[
    { value: 'apple', label: 'Apple' },
    { value: 'banana', label: 'Banana' },
  ]}
/>
```

## Theming

Listbox surfaces map to **semantic surface and selection roles**, never Tailwind palette steps.

| Part | CSS roles |
| --- | --- |
| List surface (`rui-listbox`) | `background` |
| Bordered variant | `border`, `radius-container` |
| Option | `on-background`, `radius-control`, `space-control-*` |
| Option hover | `surface-container-low` |
| Active option (visual focus) | `surface-container`, `on-background` |
| Selected option | `primary`, `on-primary` |
| Focus ring | `focus-ring` |

Set the listed semantic custom properties in your app's theme stylesheet, such as `--primary` and `--on-primary` for selected options. Embedded listboxes inherit the popup surface from the parent shell. Indicator layout uses `:has([data-listbox-option-indicator])`; there is no public CSS variable for the check glyph — replace it with helper children.

## API

`RuiListbox` is a custom element (`<rui-listbox>`) around a `role="listbox"` surface. It is not form-associated.

### Attributes

| 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 for the list. |
| `disabled` | `boolean` | `false` | Disable all selection. |
| `selection-mode` | `single` · `multiple` | `single` | Single or multiple selection. |
| `embedded` | `boolean` | `false` | Parent-owned listbox: no border chrome, selection handled by the parent. |
| `bordered` | `boolean` | follows `embedded` | Override the border (`true` standalone, `false` embedded). |

### Light-DOM contract

| Target | Required | Host writes | Author owns |
| --- | --- | --- | --- |
| `[role="listbox"]` | yes | `id`, `aria-multiselectable` (multiple mode) | the list node |
| `[role="option"]` | yes | `aria-selected`, roving `tabIndex` | `data-value`, `data-label`, `aria-disabled`, `hidden` |
| `data-value` | per option | — | selection identity; fallback trimmed text |
| `data-label` | per option | — | accessible name when children include decorative nodes |

Do not set `aria-selected` or `tabIndex` on options. Nested hosts: none.

### Events

| Event | Detail | Description |
| --- | --- | --- |
| `rui-change` | `{ value: string[] }` | Fired when an option is selected. |

### View helpers

| Component | Target stamped | Notes |
| --- | --- | --- |
| `RuiListbox` | `<rui-listbox>` + `[role="listbox"]` shell | Accepts `options` or children. |
| `RuiListboxOption` | `[role="option"]`, `data-value`, `data-label` | — |
| `RuiListboxOptionIndicator` | `[data-listbox-option-indicator]` | Decorative; not queried by the host. |

### CSS classes

| Class | Description |
| --- | --- |
| `.rui-listbox` | Scrollable option list surface (`role="listbox"`). |
| `.rui-listbox--bordered` | Bordered standalone listbox. |
| `.rui-listbox__option` | Selectable list option (`RuiListboxOption`). |
| `.rui-listbox__option-indicator` | Selected-state indicator inside an option. |

### Theme roles

| Part | CSS variables consumed |
| --- | --- |
| List surface | `--background` |
| Bordered variant | `--border`, `--radius-container` |
| Option | `--on-background`, `--radius-control`, `--space-control-x`, `--space-control-y` |
| Option hover / active | `--surface-container-low`, `--surface-container` |
| Selected option | `--primary`, `--on-primary` |
| Focus ring | `--focus-ring` |

## Accessibility

- Options expose `role="option"` with `aria-selected` for the current choice.
- Multiple mode exposes `aria-multiselectable="true"`. The visual indicator is decorative; selection remains on `aria-selected`.
- Arrow keys move focus between options. In multiple mode they do not toggle; Space or Enter does.
- Provide a `label` or external heading so users know what the list represents.
