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
TextFieldabove the list and point it at the list witharia-controls - filter the rendered
ListboxOptionelements 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
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
valueandonChangeValue - render one
<input type='hidden'>per selected value with the fieldname - 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
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 formonSubmit - set
aria-invalidon the list and link the message witharia-describedbyonly 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
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
ListboxGroupand name it witharia-labelledbypointing at its heading - give the list a
maxHeight: it scrolls by itself and keeps the active option in view - pass
labelto 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
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
disabledfrom 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
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>
<SwitchInputComparison
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:
Listboxshows every choice but takes vertical spaceSelectsaves 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,Endand 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:
Listboxis one tab stop with list navigation and no native form valueRadiois 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:
Listboxtreats the selection as one array valueCheckboxtreats 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
nameinside 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.
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.
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>
<SwitchInputTyping 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
textContentin the developer tools - check whether the option has a
data-labelattribute
Fix
Pass the plain name as label on each ListboxOption.
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
valueafter the reset - check whether
onChangeValuewritesundefinedinto your state
Fix
Store null for an empty single choice, and map an undefined from onChangeValue to null with next ?? null.
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
idattributes - 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
heightormax-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.
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);