Skip to content

Listbox

A list of options to highlight and select with the keyboard or the pointer, with groups, typeahead, and an input that drives it from outside.

Sine
Triangle
Sawtooth
Square
Noise
import { createListCollection, Listbox } from '@contour-ui/headless-react';

const collection = createListCollection({
  items: [
    { value: 'sine', label: 'Sine' },
    { value: 'saw', label: 'Sawtooth' },
  ],
});

<Listbox.Root collection={collection}>
  <Listbox.Label>Waveform</Listbox.Label>
  <Listbox.Content>
    {collection.items.map((item) => (
      <Listbox.Item key={item.value} item={item}>
        <Listbox.ItemText>{item.label}</Listbox.ItemText>
        <Listbox.ItemIndicator>✓</Listbox.ItemIndicator>
      </Listbox.Item>
    ))}
  </Listbox.Content>
</Listbox.Root>;

Listbox shows the items of a collection, highlights one of them, where the keyboard acts, and selects one or several. Focus the list above with Tab: the arrows, Home and End move the highlight past the disabled Noise, typing the first letters of an item highlights it, and Enter, Space or a click selects. The styled Listbox is this component with the contour-ui look.

The collection

createListCollection (exported by @contour-ui/core, and again here) takes the items and tells Listbox how to read them. A string is its own value and text; an object gives its value, its label and disabled, or you pass the functions that read them:

const collection = createListCollection({
  items: results,
  itemToValue: (result) => result.url,
  itemToString: (result) => result.title,
  isItemDisabled: (result) => result.draft,
  groupBy: (result) => result.page,
});

A collection never changes: for other items, create another one, inside useMemo in a component. Every item needs a value of its own. Typeahead matches the text, ignoring case. With groupBy, the collection holds its items group by group, in the order the groups first come, and collection.groups lists them with their key: render them in that order, so the keys go through the items as they are shown.

Selection

value is the list of the selected values: defaultValue for a listbox that keeps it, value and onValueChange({ value, items }) to keep it yourself. onSelect({ value, item }) reports each item the user selects, with Enter, Space or a click, also one that was selected already, and none that a multiple selection toggles off.

  • selectionMode="multiple" toggles items instead of replacing the selection.
  • deselectable clears a single selection when its item is selected again.
  • selectOnHighlight makes a single selection follow the keys.

An input

Listbox.Input drives the list from a text field, as a search box does: the focus stays in the input, ↓ and ↑ move the highlight in the list (aria-activedescendant), and Enter selects the highlighted item. Home, End, ← and → stay with the text. Filter the collection by the value of the input yourself; with autoHighlight, the first item is highlighted as the user types, so Enter selects the best match at once. Listbox.Empty renders only while the collection is empty.

const [query, setQuery] = useState('');
const collection = useMemo(
  () => createListCollection({ items: pages.filter((page) => page.title.includes(query)) }),
  [query],
);

<Listbox.Root collection={collection} autoHighlight onSelect={({ item }) => navigate(item.url)}>
  <Listbox.Input
    aria-label="Search"
    value={query}
    onChange={(event) => setQuery(event.target.value)}
  />
  <Listbox.Content aria-label="Results">…</Listbox.Content>
  <Listbox.Empty>No results</Listbox.Empty>
</Listbox.Root>;

A click on an item keeps the focus in the input. While the input is there, the list leaves the tab order: the input takes the keys.

Styling

Headless renders no CSS. Style the parts by their data attributes, the state included:

[data-scope='listbox'][data-part='content'] {
  max-block-size: 20rem;
  overflow-y: auto;
}

[data-scope='listbox'][data-part='item'][data-highlighted] {
  background: #eee;
}

[data-scope='listbox'][data-part='item'][data-disabled] {
  opacity: 0.5;
}

data-highlighted marks the item the keyboard acts on, and the item under the pointer (highlightOnHover, on by default), so one style serves both. Give the list a height to scroll it: the highlighted item scrolls into view inside the list, never the page.

Anatomy

PartElementAttributes
Listbox.Rootdivdata-scope="listbox" data-part="root", data-orientation, data-disabled
Listbox.Labellabeldata-part="label", id, for (the input, when there is one)
Listbox.Inputinputdata-part="input", role="combobox", aria-expanded="true", aria-controls, aria-activedescendant, aria-autocomplete="list", autocomplete="off"
Listbox.Contentdivdata-part="content", role="listbox", aria-labelledby, aria-activedescendant, aria-multiselectable, aria-orientation, data-empty, tabindex
Listbox.ItemGroupdivdata-part="item-group", role="group", aria-labelledby
Listbox.ItemGroupLabeldivdata-part="item-group-label", id
Listbox.Itemdivdata-part="item", role="option", id, aria-selected, aria-disabled, data-value, data-highlighted, data-selected, data-disabled
Listbox.ItemTextspandata-part="item-text", data-highlighted, data-selected, data-disabled
Listbox.ItemIndicatorspandata-part="item-indicator", aria-hidden="true", hidden while not selected
Listbox.Emptydivdata-part="empty"; rendered only while the collection is empty

Every part carries data-scope="listbox"; the state attributes are present, and empty, while the state holds. The parts reference one another by id: the label names the list and is for the input, the input controls the list. Give a part an id of your own, and the others follow it.

Props

Listbox.Root:

PropTypeDefault
collectionListCollection, from createListCollectionrequired
valuestring[]: the selected values—
defaultValuestring[][]
onValueChange(details: { value: string[]; items: T[] }) => void—
highlightedValuestring | null—
defaultHighlightedValuestring | nullnull
onHighlightChange(details: { highlightedValue; highlightedItem; highlightedIndex }) => void—
onSelect(details: { value: string; item: T }) => void—
selectionMode'single', 'multiple''single'
deselectablebooleanfalse
selectOnHighlightbooleanfalse
loopFocusboolean: the arrows wrap around at the endsfalse
typeaheadbooleantrue
highlightOnHoverbooleantrue
autoHighlightbooleanfalse
disabledboolean: ignores the user; the value can still be setfalse
orientation'vertical', 'horizontal''vertical'

Listbox.Item takes item, the item of the collection it shows. Every part takes asChild and the props of its element, such as className, style and ref.

Keyboard

WhereKeyAction
List↓ ↑Highlights the next or the previous enabled item (→ ← in a row)
ListHome EndHighlights the first or the last enabled item
ListLettersHighlight the next item whose text starts with them
ListEnterSelects the highlighted item
ListSpaceSelects the highlighted item; while typing a name, adds a space to it
Input↓ ↑Highlights the next or the previous enabled item
InputEnterSelects the highlighted item; without one, submits a form
InputHome End ← →Move the caret in the text

Typeahead joins the letters typed less than half a second apart: "ba" finds "Bach" after "Banana". Pressing the same letter again moves on to the next item that starts with it.

Accessibility

  • One focused element. The list (or the input) keeps the focus, and aria-activedescendant points to the highlighted option, so screen readers announce it as the keys move. The highlight goes when the focus leaves the listbox, and comes back on the selected item, or the first one.
  • The input is a combobox that controls the list, as the field of a command palette: screen readers announce it with its options, and the list scrolls with its keys, so the list needs no focus of its own. The list is shown with the field, so the field is always expanded (aria-expanded="true"); pass aria-expanded={false} while you hide the list.
  • Names. Listbox.Label names the list and the input. Without it, give each an aria-label.
  • States. Options carry aria-selected and, when disabled, aria-disabled; a multiple selection has aria-multiselectable. The indicator is hidden from assistive technology.
  • The empty state is not in the list: a list holds only options and groups. Announce result counts with a polite status of your own.

How it works

Listbox.Root runs the listbox machine of @contour-ui/core, listbox.machine, with useMachine. The machine has one state; the events change its context (the selection, the highlight, whether it has focus), and it needs nothing from the platform. The parts turn the keys into intents with getListIntent and nextTypeahead of core, and send the events:

EventSent byWhat happens
HIGHLIGHT.FIRST, .LAST, .NEXT, .PREVIOUSHome, End, ↓, ↑the highlight moves to an enabled item; with selectOnHighlight, selects
TYPEAHEADletters in the listthe item whose text starts with the query is highlighted
ITEM.HOVER, POINTER.LEAVEthe pointerhighlights the item under it; leaving a list without focus clears it
ITEM.CLICKa clickhighlights and selects the item
HIGHLIGHTED.SELECTEnter, Spaceselects the highlighted item
CONTENT.FOCUS, INPUT.FOCUS, BLURfocushighlights the selected or first item; clears the highlight on the way out
HIGHLIGHT.SET, ITEM.SELECT, VALUE.SEThighlightValue, selectValue, setValueas their names say

The typeahead query is built in the adapter, from the time stamps of the key events: core keeps no clock. A new collection with other items clears a highlighted item that is gone, or, with autoHighlight while the listbox has focus, highlights the first item.

Hooks

useListbox runs the machine without the parts: it takes the props of Listbox.Root and returns the API (value, selectedItems, highlightedValue, highlightedItem, getItemState(item), highlightValue(), highlightNext(), selectValue(), setValue(), clearValue()…) and the props of each element: getLabelProps(), getInputProps(), getContentProps(), getItemProps({ item }), getItemGroupProps({ labelId }) and the rest.

import { useListbox } from '@contour-ui/headless-react';

function Waveforms({ collection }) {
  const listbox = useListbox({ collection });
  return (
    <div {...listbox.getContentProps()} aria-label="Waveform">
      {collection.items.map((item) => (
        <div key={item.value} {...listbox.getItemProps({ item })}>
          {item.label}
        </div>
      ))}
    </div>
  );
}

Inside Listbox.Root, useListboxContext() returns the same API, and inside Listbox.Item, useListboxItemContext() returns { item, state }, for parts of your own:

import { useListboxContext } from '@contour-ui/headless-react';

function SelectionCount() {
  const { value } = useListboxContext();
  return <p>{`${value.length} selected`}</p>;
}