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
- :has()
- Oklab and OkLCh — comes from the design tokens
- Logical properties
- :focus-visible
- :where()
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
ListboxPropsListboxOptionPropsListboxGroupPropsListboxValue
Basic Usage
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
labelfor typeahead
Edge Cases
value={null}clears a controlled single list, whileundefinedmakes it uncontrolled.- A disabled list disables all its options, whatever their own
disabledis. - 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-labeloraria-labelledby. - Giving two options the same
value. - Using
Listboxfor 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.
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.
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.
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.
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.
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.
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.
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.
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>
<SwitchInputEdge
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'.
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.
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.
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
ListboxOptionandListboxGroup - Needs a name through
aria-labelledbyoraria-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 aListboxGroup valueis astringor anumberand must be unique in the list: it builds the optionidlabelgives 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
Listboxand putListboxOptionchildren in it - Name it with
aria-labelledbypointing to your heading, or witharia-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
Listbox- optional
ListboxGroupwith its heading ListboxOption
Valid Composition Patterns
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.