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)
| Prop | Type | Description |
|---|---|---|
value | string | Controlled value |
defaultValue | string | Uncontrolled initial value |
onValueChange | (value: string) => void | Fires on selection |
disabled | boolean | Disables the select |
Accessibility
- Trigger has
role="combobox",aria-expanded,aria-controls,aria-activedescendant - Listbox has
role="listbox"; options haverole="option"witharia-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