Skip to Content

Select

Headless combobox + listbox with type-ahead and full keyboard navigation.

Anatomy

import { Select } from "@spacing-ui/core"; <Select defaultValue="apple" onValueChange={(v) => console.log(v)}> {({ value }) => ( <> <Select.Trigger>{value ?? "Pick a fruit"}</Select.Trigger> <Select.Content> <Select.Option value="apple">Apple</Select.Option> <Select.Option value="banana">Banana</Select.Option> <Select.Option value="cherry">Cherry</Select.Option> </Select.Content> </> )} </Select>;

API

Select (Root)

PropTypeDescription
valuestringControlled value
defaultValuestringUncontrolled initial value
onValueChange(value: string) => voidFires on selection
disabledbooleanDisables the select

Accessibility

  • Trigger has role="combobox", aria-expanded, aria-controls, aria-activedescendant
  • Listbox has role="listbox"; options have role="option" with aria-selected
  • Keyboard: ArrowDown/Up, Home, End, Enter, Space, Escape, Tab
  • Type-ahead: typing characters within 500ms focuses the matching option

Examples

Grouped options

<Select defaultValue="apple"> {() => ( <> <Select.Trigger>Pick one</Select.Trigger> <Select.Content> <Select.Group label="Fruit"> <Select.Option value="apple">Apple</Select.Option> <Select.Option value="banana">Banana</Select.Option> </Select.Group> <Select.Group label="Veg"> <Select.Option value="carrot">Carrot</Select.Option> </Select.Group> </Select.Content> </> )} </Select>

Type-ahead behavior

Typing characters while the listbox is open focuses the first option whose visible text starts with the typed sequence. The buffer resets after 500 ms of inactivity, so repeatedly pressing “a” cycles between options starting with “a”.

Async options

Options can be rendered from server-fetched data. Give the trigger a stable label while loading:

function CountrySelect() { const { data, isLoading } = useCountries(); return ( <Select disabled={isLoading}> {({ value }) => ( <> <Select.Trigger>{value ?? (isLoading ? "Loading..." : "Select")}</Select.Trigger> <Select.Content> {data?.map((c) => ( <Select.Option key={c.code} value={c.code}>{c.name}</Select.Option> ))} </Select.Content> </> )} </Select> ); }

Live demo

Testing

await userEvent.click(screen.getByRole("combobox")); const listbox = await screen.findByRole("listbox"); await userEvent.click(within(listbox).getByRole("option", { name: "Banana" })); expect(screen.getByRole("combobox")).toHaveTextContent("banana");
Last updated on