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 filters it, as in a search box.

Alert
Alert
Status
Button
Button
Variants
Sizes
Card
Card
Variants
CodeBlock
CodeBlock
Copy

Opened: nothing yet

import { createListCollection, Listbox } from '@contour-ui/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.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. It is the Headless Listbox with the look of contour-ui: the highlighted item on the hover background, a check mark in the accent color on the selected ones. The list has no frame of its own, so it sits on the card, popover or dialog around it.

The collection

createListCollection 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,
});

A collection never changes: for other items, create another one, inside useMemo in a component. Every item needs a value of its own, which identifies it in the selection, as the highlighted item, and in the ids of the options. The text is what typeahead matches. Disabled items are shown, but the keys skip them and they cannot be selected.

A single selection

Without an input, the list takes the keys itself. Tab focuses it and highlights the selected item, or the first one; the arrows, Home and End move the highlight, typing the first letters of an item highlights it, and Enter, Space or a click selects. Listbox.ItemIndicator shows the check mark of the selected items, and keeps its room on the others, so the rows line up.

Sine
Triangle
Sawtooth
Square
Noise

The value is the list of the selected values: defaultValue={['square']} in a listbox that keeps it, or value and onValueChange to keep it yourself. onValueChange receives { value, items }.

const [value, setValue] = useState(['square']);

<Listbox.Root
  collection={collection}
  value={value}
  onValueChange={(details) => setValue(details.value)}
>
  …
</Listbox.Root>;
  • selectionMode="multiple": each selection toggles an item, and the list is aria-multiselectable.
  • deselectable: selecting the selected item of a single selection clears it.
  • selectOnHighlight: in a single selection, the selection follows the keys, as in a list of presets that load as you browse them.
  • loopFocus: the arrows wrap around from the last item to the first, and back.
  • typeahead={false}: letters do nothing.
  • orientation="horizontal": the items line up in a row, and ← and → move the highlight.

Listbox.Input is a text field above the list, as in the search of this site. The focus stays in it: ↓ and ↑ move the highlight in the list, Enter selects the highlighted item, and Home, End, ← and → move the caret, as in any text field. The input filters nothing by itself: keep its value in your state and build the collection from it. With autoHighlight, the first result is highlighted as you type, so Enter opens it at once.

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

<Listbox.Root collection={collection} autoHighlight onSelect={({ value }) => navigate(value)}>
  <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>;

onSelect reports each item the user selects, with Enter, Space or a click, as { value, item }: also an item that was already selected, so a search box can open the same result twice. Listbox.Empty shows only while the collection has no items. Put it after Listbox.Content, not inside: a list holds only options and groups.

Groups

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 each group in a Listbox.ItemGroup with a Listbox.ItemGroupLabel, so the keys go through the items in the order they are shown:

const collection = createListCollection({ items: results, groupBy: (result) => result.page });

{
  collection.groups.map((group) => (
    <Listbox.ItemGroup key={group.key}>
      <Listbox.ItemGroupLabel>{group.key}</Listbox.ItemGroupLabel>
      {group.items.map((result) => (
        <Listbox.Item key={result.url} item={result}>
          <Listbox.ItemText>{result.text}</Listbox.ItemText>
        </Listbox.Item>
      ))}
    </Listbox.ItemGroup>
  ));
}

Scrolling

The list grows with its items. Give Listbox.Content a height, such as max-block-size: 20rem, and it scrolls: the highlighted item then scrolls into view inside the list as the keys move it, and the page stays where it is. The pointer moves the highlight too (highlightOnHover, on by default), so the item under it and the highlighted item are never two different ones.

Anatomy

PartElementClassAttributes
Listbox.Rootdivcontour-listboxdata-scope="listbox" data-part="root", data-orientation, data-disabled
Listbox.Labellabelcontour-listbox__labeldata-part="label", id, for (the input, when there is one)
Listbox.Inputinputcontour-listbox__inputdata-part="input", role="combobox", aria-expanded="true", aria-controls, aria-activedescendant, aria-autocomplete="list", autocomplete="off"
Listbox.Contentdivcontour-listbox__contentdata-part="content", role="listbox", aria-labelledby, aria-activedescendant, aria-multiselectable, aria-orientation, data-empty, tabindex
Listbox.ItemGroupdivcontour-listbox__item-groupdata-part="item-group", role="group", aria-labelledby
Listbox.ItemGroupLabeldivcontour-listbox__item-group-labeldata-part="item-group-label", id
Listbox.Itemdivcontour-listbox__itemdata-part="item", role="option", id, aria-selected, aria-disabled, data-value, data-highlighted, data-selected, data-disabled
Listbox.ItemTextspancontour-listbox__item-textdata-part="item-text", data-highlighted, data-selected, data-disabled
Listbox.ItemIndicatorspancontour-listbox__item-indicatordata-part="item-indicator", aria-hidden="true", hidden while not selected
Listbox.Emptydivcontour-listbox__emptydata-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, the input controls it, and aria-activedescendant points to the highlighted item. 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: each item the user selects—
selectionMode'single', 'multiple''single'
deselectableboolean: selecting the selected item clears a single selectionfalse
selectOnHighlightboolean: a single selection follows the keysfalse
loopFocusboolean: the arrows wrap aroundfalse
typeaheadboolean: typed letters highlight an itemtrue
highlightOnHoverboolean: the pointer highlights the item under ittrue
autoHighlightboolean: the first item is highlighted as the user types in the inputfalse
disabledbooleanfalse
orientation'vertical', 'horizontal''vertical'

Listbox.Item takes item, the item of the collection it shows. Listbox.ItemIndicator shows a check mark unless you pass children. Every part takes asChild and the props of its element, such as className, style and ref; className is merged with the part's class.

Keyboard

In the list, when it has focus:

KeyAction
↓ ↑Highlights the next or the previous enabled item (→ ← in a row)
Home EndHighlights the first or the last enabled item
LettersHighlight the next item whose text starts with them
EnterSelects the highlighted item
SpaceSelects the highlighted item; while typing a name, adds a space to it
TabMoves focus out of the list

In the input:

KeyAction
↓ ↑Highlights the next or the previous enabled item
EnterSelects the highlighted item; without one, submits a form
Home End ← →Move the caret in the text

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 items are never focused themselves.
  • The input is a combobox that controls the list, as in the search of this site: 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 input, so the input is always expanded (aria-expanded="true"); pass aria-expanded={false} while you hide the list.
  • Names. Listbox.Label names the list, and the input when there is one. Without a label, give the list an aria-label, and the input one too.
  • Selection. Every option has aria-selected; the check mark is hidden from assistive technology, which hears the selection from the option. A multiple selection has aria-multiselectable.
  • Disabled items have aria-disabled and stay in the list, so they are announced; the keys and the pointer skip them.
  • The empty state is text next to the list, not inside it. To announce how many results a search found, put a polite status of your own next to the input.

Tokens

TokenUsed for
--ct-color-bg-hoverthe highlighted item
--ct-color-accent-fg, --ct-size-icon-smthe check mark of a selected item
--ct-color-fg-default, --ct-text-bodythe items and the text of the input
--ct-color-fg-disableddisabled items, the label and input too
--ct-space-control-gap, --ct-space-control-inset, --ct-size-control-mdthe padding and the height of the items
--ct-radius-controlthe corners of the items and of the input
--ct-text-caption, --ct-color-fg-mutedthe labels of groups
--ct-text-labelthe label
--ct-color-border-default, --ct-color-border-strong, --ct-border-width-defaultthe border of the input, on hover
--ct-color-fg-mutedthe placeholder of the input, the empty state
--ct-color-border-focus, --ct-border-width-focusthe focus rings of the input and the list