Skip to content
v2.5.0
English
Русский
Documentation

Listbox

Listbox is an always visible list to choose one or several options from, like a native select with size.

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
filtered-options.tsx · 47 lines
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']);

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 with SelectHiddenSelect
listbox-in-form.tsx · 49 lines
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();

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
explain-an-empty-required-choice.tsx · 51 lines
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);

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
groups-and-a-scrolling-list.tsx · 57 lines
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' },
    ],

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
disabled-list.tsx · 28 lines
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

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
  • the value must submit natively without extra wiring; use Radio or 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.

listbox-in-form.tsx · 49 lines
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();

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.

disabled-list.tsx · 28 lines
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

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.

rich-content-with-a-label-for-typeahead.tsx · 33 lines
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)' }}>

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.

value-with-a-reset.tsx · 31 lines
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);

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.

many-options.tsx · 32 lines
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);