# Listbox Guides

## Patterns

### Filterable List

When to use:

- the list is long, but the options should stay visible while the user narrows them down
- several choices are made from the same set, such as labels or tags

Composition notes:

- render a `TextField` above the list and point it at the list with `aria-controls`
- filter the rendered `ListboxOption` elements from your own state: the list reads options from the DOM, so nothing needs to be registered
- keep the selection outside the filter, so choices hidden by the query stay selected
- when nothing matches, hide the list and show a message with `role='status'` instead of an empty box

Trade-offs:

- the filter and the list are two tab stops, so arrow keys work only after the user tabs into the list
- the list needs a `maxHeight`, or it grows with every option the filter lets through

```tsx
import { Listbox, ListboxOption, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { useId, useState } from 'react';

const labels = [
  'bug', 'feature', 'documentation', 'design', 'performance', 'security',
  'accessibility', 'good first issue', 'help wanted', 'needs triage', 'wontfix', 'duplicate',
];

export default function FilteredOptions() {
  const id = useId();
  const [query, setQuery] = useState('');
  const [selected, setSelected] = useState<string[]>(['bug']);

  const matches = labels.filter((label) => label.includes(query.trim().toLowerCase()));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 300 }}>
      <TextField size='sm'>
        <TextFieldInput
          aria-label='Filter labels'
          aria-controls={id}
          placeholder='Filter labels'
          value={query}
          onChange={(event) => setQuery(event.currentTarget.value)}
        />
      </TextField>
      <Listbox
        id={id}
        aria-label='Labels'
        multiple
        value={selected}
        onChangeValue={setSelected}
        style={{ display: matches.length === 0 ? 'none' : undefined, maxHeight: 220, border: '1px solid var(--separator-opaque)' }}
      >
        {matches.map((label) => (
          <ListboxOption key={label} value={label}>
            {label}
          </ListboxOption>
        ))}
      </Listbox>
      {matches.length === 0 && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>No labels match “{query}”.</Text>
      )}
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>Selected: {selected.join(', ') || 'none'}</Text>
    </div>
  );
}
```

### Selection Submitted With A Form

When to use:

- the always visible list is part of a form that posts its data
- the form has a reset button that should restore the initial choice

Composition notes:

- keep the value in state with `value` and `onChangeValue`
- render one `<input type='hidden'>` per selected value with the field `name`
- restore the initial state in the form `onReset`, because the list does not listen to the form

Trade-offs:

- the form reads the same state the list shows, so the two cannot drift apart
- every list in a form needs this wiring; for a compact field that submits by itself, use [Select](https://vega-ui.dev/components/select/) with `SelectHiddenSelect`

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const initialChannels = ['email'];

export default function ListboxInForm() {
  const labelId = useId();
  const [channels, setChannels] = useState<string[]>(initialChannels);
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(data.getAll('channel').join(', ') || 'nothing');
  };

  const onReset = () => {
    setChannels(initialChannels);
    setSubmitted('');
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={onReset}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
    >
      <Text id={labelId} size={3} fontWeight={500}>Notify me about new orders by</Text>
      <Listbox aria-labelledby={labelId} multiple value={channels} onChangeValue={setChannels} style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='email'>Email</ListboxOption>
        <ListboxOption value='sms'>SMS</ListboxOption>
        <ListboxOption value='push'>Push notification</ListboxOption>
        <ListboxOption value='slack'>Slack</ListboxOption>
      </Listbox>
      {channels.map((channel) => (
        <input key={channel} type='hidden' name='channel' value={channel} />
      ))}
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        <Button type='submit'>Save</Button>
      </div>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Saved channels: {submitted}
        </Text>
      )}
    </form>
  );
}
```

### Required Choice

When to use:

- the form must not continue until one option is chosen
- the list starts without a selection

Composition notes:

- start from `value={null}` and check it in the form `onSubmit`
- set `aria-invalid` on the list and link the message with `aria-describedby` only while the error is shown
- clear the error in `onChangeValue`, as soon as the user chooses

Trade-offs:

- the message is announced together with the list name when the user returns to it
- the list has no error style of its own, so the message carries the error

```tsx
import { Button, HelperText, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function ExplainAnEmptyRequiredChoice() {
  const id = useId();
  const errorId = `${id}-error`;
  const [plan, setPlan] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);
  const [done, setDone] = useState(false);

  const onChangeValue = (next: string | undefined) => {
    setPlan(next ?? null);
    setInvalid(false);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (plan === null) {
      setInvalid(true);
      return;
    }

    setDone(true);
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Text id={`${id}-label`} size={3} fontWeight={500}>Workspace plan</Text>
      <Listbox
        aria-labelledby={`${id}-label`}
        aria-invalid={invalid}
        aria-describedby={invalid ? errorId : undefined}
        value={plan}
        onChangeValue={onChangeValue}
        style={{ border: '1px solid var(--separator-opaque)' }}
      >
        <ListboxOption value='free'>Free, up to 3 members</ListboxOption>
        <ListboxOption value='team'>Team, $12 per member</ListboxOption>
        <ListboxOption value='business'>Business, $24 per member</ListboxOption>
      </Listbox>
      {invalid && <HelperText id={errorId} error>Choose a plan to create the workspace.</HelperText>}
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Create workspace</Button>
      {done && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Workspace created on the {plan} plan.
        </Text>
      )}
    </form>
  );
}
```

### Grouped Scrolling List

When to use:

- options fall into categories the user scans by, such as countries or teams
- the list is too long to show in full

Composition notes:

- wrap each category in `ListboxGroup` and name it with `aria-labelledby` pointing at its heading
- give the list a `maxHeight`: it scrolls by itself and keeps the active option in view
- pass `label` to options with extra markup, so typeahead matches the name

Trade-offs:

- headings make long lists easier to scan
- headings are not options, so arrow keys skip them and typeahead does not match them

```tsx
import { Listbox, ListboxGroup, ListboxOption, Text } from '@vega-ui/react';
import { useId } from 'react';

const countries = [
  {
    name: 'France',
    airports: [
      { code: 'CDG', name: 'Paris Charles de Gaulle' },
      { code: 'ORY', name: 'Paris Orly' },
      { code: 'NCE', name: 'Nice Côte d’Azur' },
      { code: 'LYS', name: 'Lyon Saint-Exupéry' },
    ],
  },
  {
    name: 'Germany',
    airports: [
      { code: 'FRA', name: 'Frankfurt' },
      { code: 'MUC', name: 'Munich' },
      { code: 'BER', name: 'Berlin Brandenburg' },
    ],
  },
  {
    name: 'Japan',
    airports: [
      { code: 'HND', name: 'Tokyo Haneda' },
      { code: 'NRT', name: 'Tokyo Narita' },
      { code: 'KIX', name: 'Osaka Kansai' },
    ],
  },
];

export default function GroupsAndAScrollingList() {
  const id = useId();

  return (
    <Listbox aria-label='Departure airport' defaultValue='MUC' style={{ width: '100%', maxWidth: 320, maxHeight: 260, border: '1px solid var(--separator-opaque)' }}>
      {countries.map((country) => (
        <ListboxGroup key={country.name} aria-labelledby={`${id}-${country.name}`}>
          <Text
            id={`${id}-${country.name}`}
            size={2}
            fontWeight={600}
            style={{ paddingInline: 'var(--spacing-10)', paddingBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}
          >
            {country.name}
          </Text>
          {country.airports.map((airport) => (
            <ListboxOption key={airport.code} value={airport.code} label={airport.name} style={{ gap: 'var(--spacing-8)' }}>
              {airport.name}
              <span style={{ marginInlineStart: 'auto', opacity: 0.7 }}>{airport.code}</span>
            </ListboxOption>
          ))}
        </ListboxGroup>
      ))}
    </Listbox>
  );
}
```

### List Controlled By Another Setting

When to use:

- the choice only matters when another setting allows it, such as a manual override of an automatic default

Composition notes:

- pass `disabled` from the state of the other control
- keep the value while the list is disabled, so turning the setting off restores the previous choice

Trade-offs:

- users see the choices they would get, even while they cannot pick one
- a disabled list leaves the tab order, so it cannot be focused to read its options with the keyboard

```tsx
import { Label, Listbox, ListboxOption, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';
import { useId, useState } from 'react';

export default function DisabledList() {
  const switchId = useId();
  const [automatic, setAutomatic] = useState(true);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Switch>
          <SwitchInput
            id={switchId}
            checked={automatic}
            onChange={(event) => setAutomatic(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Pick the region automatically</Label>
      </div>
      <Listbox aria-label='Server region' disabled={automatic} defaultValue='eu-central' style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='us-east'>US East (Virginia)</ListboxOption>
        <ListboxOption value='eu-central'>EU Central (Frankfurt)</ListboxOption>
        <ListboxOption value='ap-northeast'>Asia Pacific (Tokyo)</ListboxOption>
      </Listbox>
    </div>
  );
}
```

## Comparison

### Quick Decision Rule

Choose `Listbox` when the options should stay visible as one list, the user picks one or several of them, and the list may be long enough to scroll. Choose a compact field or native inputs when space is tight or the value must submit with a form by itself.

### `Listbox` vs `Select`

Use `Listbox` when:

- the options should stay visible without opening anything
- the user picks several values, with `multiple`
- the list is the main content of a panel, such as a settings column or a picker

Use `Select` when:

- the field sits in a dense form and should take one row
- the choice is single and the options can stay hidden until needed
- the value should submit with the form through `SelectHiddenSelect`

Trade-off:

- `Listbox` shows every choice but takes vertical space
- `Select` saves space but hides the choices behind a click

### `Listbox` vs `Radio`

Use `Listbox` when:

- there are many options and the list needs to scroll
- the user benefits from arrow keys, `Home`, `End` and typeahead across all options

Use `Radio` when:

- there are a few options, each with room for a description
- the choice must submit with a form natively under one `name`

Trade-off:

- `Listbox` is one tab stop with list navigation and no native form value
- `Radio` is native and form-ready, but long groups become hard to scan

### `Listbox` vs `Checkbox`

Use `Listbox` with `multiple` when:

- the choices are values of one field, such as labels or channels
- the set is long and benefits from typeahead and a scrolling area

Use `Checkbox` when:

- each choice is an independent yes or no setting
- every choice must submit natively, or needs an indeterminate "select all"

Trade-off:

- `Listbox` treats the selection as one array value
- `Checkbox` treats every choice as its own control

### `Listbox` vs `SegmentedControl`

Use `Listbox` when:

- the options are many or their labels are long
- the list is laid out vertically

Use `SegmentedControl` when:

- there are two to five short options in one row
- switching should feel immediate, like changing a view

### Choose This Component When

- the options should always be visible
- one or several values are picked from the same set
- the list may be long and needs keyboard navigation and typeahead
- the value lives in state, and the form wiring is done by you

### Do Not Choose This Component When

- the field must stay compact in a form; use [Select](https://vega-ui.dev/components/select/)
- the value must submit natively without extra wiring; use [Radio](https://vega-ui.dev/components/radio/) or [Checkbox](https://vega-ui.dev/components/checkbox/)
- an option needs its own buttons or links inside it
- the list holds actions rather than values

## Troubleshooting

### The Selection Is Missing From The Submitted Form

#### Symptom

The list shows the chosen options, but the form data has no field for them.

#### Likely Cause

`Listbox` is not a form control. It renders a `div` with `role='listbox'`, which has no `name` and does not take part in form submission.

#### How To Verify

- submit the form and inspect `new FormData(form)`
- look for an element with the expected `name` inside the form

#### Fix

Keep the value in state and render one `<input type='hidden'>` per selected value. Restore the initial state in the form `onReset`.

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const initialChannels = ['email'];

export default function ListboxInForm() {
  const labelId = useId();
  const [channels, setChannels] = useState<string[]>(initialChannels);
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(data.getAll('channel').join(', ') || 'nothing');
  };

  const onReset = () => {
    setChannels(initialChannels);
    setSubmitted('');
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={onReset}
      style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}
    >
      <Text id={labelId} size={3} fontWeight={500}>Notify me about new orders by</Text>
      <Listbox aria-labelledby={labelId} multiple value={channels} onChangeValue={setChannels} style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='email'>Email</ListboxOption>
        <ListboxOption value='sms'>SMS</ListboxOption>
        <ListboxOption value='push'>Push notification</ListboxOption>
        <ListboxOption value='slack'>Slack</ListboxOption>
      </Listbox>
      {channels.map((channel) => (
        <input key={channel} type='hidden' name='channel' value={channel} />
      ))}
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        <Button type='submit'>Save</Button>
      </div>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Saved channels: {submitted}
        </Text>
      )}
    </form>
  );
}
```

### A Disabled Fieldset Does Not Disable The List

#### Symptom

Inputs inside `<fieldset disabled>` are locked, but the list can still be focused and changed.

#### Likely Cause

The browser disables only form controls inside a fieldset, and the list is not one.

#### How To Verify

- tab through the disabled fieldset and check whether the list takes the focus
- inspect the list: it has no `aria-disabled`

#### Fix

Pass `disabled` to `Listbox` from the same state that disables the fieldset. It disables every option and removes the list from the tab order.

```tsx
import { Label, Listbox, ListboxOption, Switch, SwitchIndicator, SwitchInput } from '@vega-ui/react';
import { useId, useState } from 'react';

export default function DisabledList() {
  const switchId = useId();
  const [automatic, setAutomatic] = useState(true);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Switch>
          <SwitchInput
            id={switchId}
            checked={automatic}
            onChange={(event) => setAutomatic(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Pick the region automatically</Label>
      </div>
      <Listbox aria-label='Server region' disabled={automatic} defaultValue='eu-central' style={{ border: '1px solid var(--separator-opaque)' }}>
        <ListboxOption value='us-east'>US East (Virginia)</ListboxOption>
        <ListboxOption value='eu-central'>EU Central (Frankfurt)</ListboxOption>
        <ListboxOption value='ap-northeast'>Asia Pacific (Tokyo)</ListboxOption>
      </Listbox>
    </div>
  );
}
```

### Typing Does Not Find Options With Rich Content

#### Symptom

Typing the first letters of a name does not move to the option, although the name is shown.

#### Likely Cause

Typeahead matches the start of the option text. Without `label`, that text is all the text content of the option, so an avatar's initials or an icon label in front of the name come first.

#### How To Verify

- check the option's `textContent` in the developer tools
- check whether the option has a `data-label` attribute

#### Fix

Pass the plain name as `label` on each `ListboxOption`.

```tsx
import { Avatar, AvatarFallback, Listbox, ListboxOption } from '@vega-ui/react';

const people = [
  { id: 'amara', initials: 'AO', name: 'Amara Okafor', email: 'amara.okafor@northwind-logistics.example' },
  { id: 'boris', initials: 'BP', name: 'Boris Petrov', email: 'boris@northwind-logistics.example' },
  { id: 'chloe', initials: 'CL', name: 'Chloé Lefèvre-Durand', email: 'chloe.lefevre-durand.finance@northwind-logistics.example' },
  { id: 'dmitri', initials: 'DV', name: 'Dmitri Volkov', email: 'dmitri.volkov@northwind-logistics.example' },
];

export default function RichContentWithALabelForTypeahead() {
  return (
    <Listbox aria-label='Assignee' style={{ width: '100%', maxWidth: 300, border: '1px solid var(--separator-opaque)' }}>
      {people.map((person) => (
        <ListboxOption
          key={person.id}
          value={person.id}
          label={person.name}
          style={{ height: 'auto', paddingBlock: 'var(--spacing-5)', gap: 'var(--spacing-6)' }}
        >
          <Avatar size='md' aria-hidden='true' style={{ flexShrink: 0 }}>
            <AvatarFallback>{person.initials}</AvatarFallback>
          </Avatar>
          <span style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
            <span>{person.name}</span>
            <span style={{ fontSize: 'var(--font-size-2)', opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {person.email}
            </span>
          </span>
        </ListboxOption>
      ))}
    </Listbox>
  );
}
```

### Clearing The Value Does Not Clear The List

#### Symptom

After a reset button sets the value to `undefined`, an option stays selected.

#### Likely Cause

`undefined` makes the list uncontrolled, so it shows its own internal selection instead of an empty one. A single list is cleared with `null`, a list with `multiple` with an empty array.

#### How To Verify

- log the value passed to `value` after the reset
- check whether `onChangeValue` writes `undefined` into your state

#### Fix

Store `null` for an empty single choice, and map an `undefined` from `onChangeValue` to `null` with `next ?? null`.

```tsx
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useState } from 'react';

const fonts = [
  { value: 'serif', label: 'Serif', family: 'Georgia, serif' },
  { value: 'sans', label: 'Sans-serif', family: 'system-ui, sans-serif' },
  { value: 'mono', label: 'Monospace', family: 'ui-monospace, monospace' },
];

export default function ValueWithAReset() {
  const [font, setFont] = useState<string | null>('serif');
  const current = fonts.find((item) => item.value === font);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 320 }}>
      <Listbox aria-label='Reading font' value={font} onChangeValue={(next) => setFont(next ?? null)} style={{ border: '1px solid var(--separator-opaque)' }}>
        {fonts.map((item) => (
          <ListboxOption key={item.value} value={item.value}>
            {item.label}
          </ListboxOption>
        ))}
      </Listbox>
      <Text size={3} style={{ fontFamily: current?.family, minHeight: 'calc(var(--line-height-3) * 2)' }}>
        The quick brown fox jumps over the lazy dog.
      </Text>
      <Button size='sm' variant='secondary' appearance='ghost' style={{ alignSelf: 'flex-end' }} disabled={font === null} onClick={() => setFont(null)}>
        Use the site default
      </Button>
    </div>
  );
}
```

### Two Options Are Highlighted And Selected Together

#### Symptom

Choosing one option selects another one too, and the arrow keys highlight both.

#### Likely Cause

The options share the same `value`. The option `id` is built from the value, and selection compares values, so the list cannot tell them apart.

#### How To Verify

- inspect the options and look for duplicate `id` attributes
- check the data the options are rendered from for repeated keys

#### Fix

Give every `ListboxOption` a unique `value`, such as a record id, and keep the visible text separate.

### The List Grows Taller Than The Layout

#### Symptom

With many options the list pushes the content below it far down the page.

#### Likely Cause

The list has no height limit by default and grows with its options.

#### How To Verify

- inspect the list and check that it has no `height` or `max-height`

#### Fix

Set `maxHeight` on the list. It scrolls by itself and keeps the active option in view while the user moves with the arrow keys. A smaller `size` fits more rows in the same space.

```tsx
import { Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useState } from 'react';

const builds = Array.from({ length: 300 }, (_, index) => {
  const number = 1480 - index;
  const branch = index % 7 === 0 ? 'release/4.2' : 'main';
  return { value: number, label: `Build ${number}`, branch };
});

export default function ManyOptions() {
  const [build, setBuild] = useState<number | null>(1480);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%', maxWidth: 320 }}>
      <Listbox
        aria-label='Build to deploy'
        value={build}
        onChangeValue={(next) => setBuild(next ?? null)}
        size='sm'
        style={{ maxHeight: 240, border: '1px solid var(--separator-opaque)' }}
      >
        {builds.map((item) => (
          <ListboxOption key={item.value} value={item.value} label={item.label}>
            {item.label}
            <span style={{ marginInlineStart: 'auto', opacity: 0.7 }}>{item.branch}</span>
          </ListboxOption>
        ))}
      </Listbox>
      <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{build === null ? 'No build selected.' : `Build ${build} will be deployed to staging.`}</Text>
    </div>
  );
}
```
