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.
Pickup point
Courier to the door
Parcel locker
listbox.tsx · 7 lines
import { Listbox, ListboxOption } from '@vega-ui/react';

<Listbox aria-label='Delivery method' defaultValue='courier'>
  <ListboxOption value='pickup'>Pickup point</ListboxOption>
  <ListboxOption value='courier'>Courier to the door</ListboxOption>
  <ListboxOption value='locker'>Parcel locker</ListboxOption>
</Listbox>
Browser support
Widely available
  • Chrome 111
  • Edge 111
  • Firefox 121
  • Safari 15.4
  • Safari on iOS 15.4
Bundle size
Tested in
20 tests

Summary

Listbox is an always visible list to choose one or several options from, like a native select with size. The focus stays on the list, which points to the active option with aria-activedescendant.

Imports

import { Listbox, ListboxGroup, ListboxOption, type ListboxGroupProps, type ListboxOptionProps, type ListboxProps, type ListboxValue } from '@vega-ui/react';

Exported Types

  • ListboxProps
  • ListboxOptionProps
  • ListboxGroupProps
  • ListboxValue

Basic Usage

basic-usage.tsx · 11 lines
import { Listbox, ListboxOption } from '@vega-ui/react';

export default function BasicUsage() {
  return (
    <Listbox aria-label='Delivery method' defaultValue='courier' style={{ width: '100%', maxWidth: 280, border: '1px solid var(--separator-opaque)' }}>
      <ListboxOption value='pickup'>Pickup point</ListboxOption>
      <ListboxOption value='courier'>Courier to the door</ListboxOption>
      <ListboxOption value='locker'>Parcel locker</ListboxOption>
    </Listbox>
  );
}

Variants

  • Selection: one value, or an array with multiple
  • Size: xs, sm, md, lg, xl
  • Content: plain text, or markup with label for typeahead

Edge Cases

  • value={null} clears a controlled single list, while undefined makes it uncontrolled.
  • A disabled list disables all its options, whatever their own disabled is.
  • Options are read from the DOM on each key press, so filtering and adding them needs no registration.
  • The height is plain CSS on the list, which scrolls by itself.

Common Mistakes

  • Leaving the list without aria-label or aria-labelledby.
  • Giving two options the same value.
  • Using Listbox for a compact form field, where Select fits better.

Examples

Basic

Basic: visible label

The list is always visible, so it usually sits under a visible heading. aria-labelledby points at that text and gives the list its accessible name; options are rendered from data.

visible-label.tsx · 26 lines
import { Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useId } from 'react';

const layouts = [
  { value: 'board', label: 'Board' },
  { value: 'list', label: 'List' },
  { value: 'timeline', label: 'Timeline' },
  { value: 'calendar', label: 'Calendar' },
];

export default function VisibleLabel() {
  const labelId = useId();

Controlled/Stateful

Controlled/Stateful: value with a reset

The parent owns the font and the preview below reads it. The reset sets null, which clears a controlled single list; undefined would make it uncontrolled.

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);

Controlled/Stateful: several choices

With multiple the value is an array. The parent derives the count and the extra price from it, and clears it with an empty array.

several-choices.tsx · 37 lines
import { Button, Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useState } from 'react';

const toppings = [
  { value: 'cheese', label: 'Extra mozzarella', price: 1.5 },
  { value: 'mushrooms', label: 'Mushrooms', price: 1 },
  { value: 'olives', label: 'Black olives', price: 1 },
  { value: 'pepperoni', label: 'Pepperoni', price: 2 },
  { value: 'jalapenos', label: 'Jalapeños', price: 0.5 },
];

export default function SeveralChoices() {

Form/Integration

Form/Integration: submitting the selection with a form

Listbox is not a form field, so the form reads hidden inputs rendered from the same state, one per selected value. The form's onReset restores the initial selection.

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();

Layout/Overlay

Layout/Overlay: groups and a scrolling list

ListboxGroup groups airports by country and is named by its heading through aria-labelledby. The list height is plain CSS: it scrolls by itself and keeps the active option in view while you move with the arrow keys.

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' },
    ],

Error

Error: explain an empty required choice

The form checks the choice on submit, sets aria-invalid on the list and links the message with aria-describedby. Choosing a plan clears 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);

Disabled

Disabled: options that cannot be chosen

Sold-out sizes stay visible with their reason but are disabled: clicks ignore them and arrow keys skip them.

disabled-options.tsx · 30 lines
import { Listbox, ListboxOption, Text } from '@vega-ui/react';
import { useId } from 'react';

const sizes = [
  { value: 'xs', label: 'XS', stock: 0 },
  { value: 's', label: 'S', stock: 4 },
  { value: 'm', label: 'M', stock: 12 },
  { value: 'l', label: 'L', stock: 0 },
  { value: 'xl', label: 'XL', stock: 2 },
];

export default function DisabledOptions() {

Disabled: the whole list

disabled on the list disables every option and removes the list from the tab order. A <fieldset disabled> does not reach it, because the list is not a form control, so pass disabled from your own state.

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

Edge

Edge: rich content with a label for typeahead

Each option holds an avatar, a name and a long email. label gives typeahead the name alone, and the email is truncated so the row keeps its width; options have a fixed height, so the two-line rows set height: 'auto'.

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)' }}>

Edge: hundreds of options

300 builds with numeric values. Home and End jump to the ends of the list, and typing "Build 13" moves to the first matching build.

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);

Edge: filtering the options

The list reads its options from the DOM on each key press, so filtering is plain rendering with no registration. The selection survives options that are filtered out, and an empty result shows a message instead of options.

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']);

Anatomy

Overview

Listbox is a compound component. The root owns the selection, the active option, keyboard navigation and typeahead, and shares them with its parts through context. Options are read from the DOM on each key press, so they need no registration and can be filtered or added freely.

Required Parts

Listbox

Root of the composition and the element with role='listbox'.

  • Required: yes
  • Owns: value, multiple, disabled, size, typeahead, the active option and keyboard handling
  • Must wrap every ListboxOption and ListboxGroup
  • Needs a name through aria-labelledby or aria-label

ListboxOption

A single choice, rendered as an Option with role='option'.

  • Required: yes, one or more
  • Must be rendered inside Listbox, directly or inside a ListboxGroup
  • value is a string or a number and must be unique in the list: it builds the option id
  • label gives typeahead plain text when the content is markup

Optional Parts

ListboxGroup

Groups related options like a native optgroup and renders role='group'.

  • Required: no
  • Place it inside Listbox and put ListboxOption children in it
  • Name it with aria-labelledby pointing to your heading, or with aria-label

Group heading

Any element of yours with an id, rendered inside the group and referenced by its aria-labelledby. The component does not export a heading part.

Hidden inputs

Listbox does not submit a value. To send the selection with a form, render <input type='hidden'> elements from the same state next to the list.

Composition Order

  1. Listbox
  2. optional ListboxGroup with its heading
  3. ListboxOption

Valid Composition Patterns

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' },
    ],

The list is named with aria-label, each group is named by its heading through aria-labelledby, and every option has a unique value. Options with markup pass label, so typeahead matches the airport name and not the code next to it.

Invalid Composition Patterns

ListboxOption outside Listbox

The row renders, but it has no list context: it never becomes selected and does not respond to the keyboard.

Option instead of ListboxOption

A plain Option has role='option', so the list reaches it with the arrow keys, but choosing it does not change the value.

Two options with the same value

Both get the same id, so they are highlighted and selected together.

Interactive controls inside an option

The focus stays on the list, so buttons, links or inputs inside an option cannot be reached with the keyboard and confuse screen readers.

A group without a name

A ListboxGroup without aria-labelledby or aria-label is announced as an unnamed group.