Combobox
A combobox lets people search a long list and choose one value from it.A combobox shows the current value in a field and opens a list with a search box, so people can type to narrow a long list before they choose. For a short list that people can scan without searching, use a Select. For a list of actions rather than values, use a Dropdown Menu.
Playground
Usage
import { Combobox } from '@kushagradhawan/kookie-ui';
const countries: Record<string, string> = { in: 'India', jp: 'Japan', gb: 'United Kingdom' };
const countryName = (code: string | null) => (code ? countries[code] : undefined);
export function CountryFilter({ value, onChange }: { value: string | null; onChange: (value: string | null) => void }) {
return (
<Combobox.Root value={value} onValueChange={onChange} displayValue={countryName} placeholder="Any country">
<Combobox.Trigger aria-label="Country" />
<Combobox.Content>
<Combobox.Input placeholder="Search countries" />
<Combobox.List>
<Combobox.Empty>No countries found</Combobox.Empty>
{Object.entries(countries).map(([code, name]) => (
<Combobox.Item key={code} value={code}>
{name}
</Combobox.Item>
))}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
);
}Anatomy
<Combobox.Root>
<Combobox.Trigger />
<Combobox.Content>
<Combobox.Input />
<Combobox.List>
<Combobox.Empty />
<Combobox.Group>
<Combobox.Label />
<Combobox.Item />
</Combobox.Group>
<Combobox.Separator />
</Combobox.List>
</Combobox.Content>
</Combobox.Root>- Root: Holds the value, the search text and the open state, and sets the size.
- Trigger: The field that shows the chosen value and opens the list.
- Value: The chosen value as text, for a trigger with a custom layout.
- Content: The panel that opens below the trigger, rendered in a portal.
- Input: The search box at the top of the panel.
- List: The scrolling list of options.
- Empty: Shows when the search matches no option.
- Item: One option. The chosen item shows a check mark.
- Group, Label and Separator: Sections of the list, a heading for a section, and a line between sections.
Best practices
Use a combobox when people know what they're looking for. Countries, time zones, people and repositories are long lists with known names, so typing a few letters is faster than scrolling. For fewer than about ten options, a Select is quicker to read.
Give the field a visible label. The trigger has the combobox role, which doesn't take its name from its content. Point a label at the trigger's id, or set aria-label. The same name labels the panel, the search box and the list.
Always add an Empty message. Without it, a search that matches nothing shows a blank panel, and people can't tell whether the list is still loading.
Show a label, not the stored value. Set displayValue on Root so the trigger shows "United Kingdom" rather than "gb". Without it, the trigger shows the raw value.
Search the words people use. Search matches the text of each item. Add keywords for other names, such as "UK" and "Britain" for the United Kingdom.
Variant
variant on Trigger sets the look of the field. raised is the default and suits most forms. Use surface or outline to match a TextField with the same variant, soft on a tinted background, and ghost in a toolbar or a table cell. The search box uses its own variant on Input, soft by default.
Size
size on Root picks one of four heights for the trigger: 28, 32, 40 and 48 pixels. The panel, the search box and the items use the same size unless you set size on Content. Size 2 is the default. size accepts a responsive object, such as { initial: '3', md: '2' }.
Examples
Search on the server
Set shouldFilter={false} when your API does the search. Control searchValue to send each query, and render only the items the server returns.
<Combobox.Root
shouldFilter={false}
searchValue={query}
onSearchValueChange={setQuery}
>
<Combobox.Trigger aria-label="Assignee" loading={isFetching} />
<Combobox.Content>
<Combobox.Input placeholder="Search people" />
<Combobox.List>
<Combobox.Empty>No one matches "{query}"</Combobox.Empty>
{results.map((person) => (
<Combobox.Item key={person.id} value={person.id}>
{person.name}
</Combobox.Item>
))}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>While loading or readOnly is set, the trigger stays focusable but doesn't open.
Grouped options
Group items under a Label when the list has natural sections, such as recent and all projects. A group whose items all fail the search hides itself.
<Combobox.List>
<Combobox.Group>
<Combobox.Label>Recent</Combobox.Label>
<Combobox.Item value="atlas">Atlas</Combobox.Item>
</Combobox.Group>
<Combobox.Separator />
<Combobox.Group>
<Combobox.Label>All projects</Combobox.Label>
<Combobox.Item value="beacon">Beacon</Combobox.Item>
<Combobox.Item value="compass">Compass</Combobox.Item>
</Combobox.Group>
</Combobox.List>Rich items
An item can hold an avatar or a second line of text. Search reads the text inside the children. When the children are components whose text isn't in the JSX, set label to the text to search.
<Combobox.Item value={user.id} label={user.name}>
<UserRow user={user} />
</Combobox.Item>Invalid value
Set error on Trigger when a form check fails. The trigger shows a red border and sets aria-invalid. Put the message in text next to the field, because the colour alone doesn't say what is wrong.
Props
Root
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | The chosen value, when you control it | |
defaultValue | string | null | null | The value at first render |
onValueChange | (value: string | null) => void | Called when someone chooses an item | |
displayValue | string | (value) => string | undefined | The text the trigger shows for the value. Pass a stable function | |
placeholder | string | 'Select an option' | Shows in the trigger when no value is chosen |
searchPlaceholder | string | 'Search...' | Shows in the empty search box |
searchValue | string | The search text, when you control it | |
defaultSearchValue | string | '' | The search text at first render |
onSearchValueChange | (value: string) => void | Called when the search text changes | |
shouldFilter | boolean | true | Filters and sorts items by the search. Set false to filter yourself |
filter | (value, search, keywords) => number | Your own match score, from 0 (hidden) to 1 | |
resetSearchOnSelect | boolean | true | Clears the search when an item is chosen |
resetSearchOnClose | boolean | true | Clears the search when the panel closes without a choice |
loop | boolean | true | Wraps the arrow keys from the last item to the first |
open | boolean | Whether the panel is open, when you control it | |
defaultOpen | boolean | false | Whether the panel is open at first render |
onOpenChange | (open: boolean) => void | Called when the panel opens or closes | |
size | '1' | '2' | '3' | '4', responsive | '2' | The height step |
color | AccentColor | Theme accent | The colour of the trigger and the panel |
disabled | boolean | false | Disables the trigger and every item |
modal | boolean | false | Blocks the rest of the page while the panel is open |
Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'raised' | 'surface' | 'soft' | 'outline' | 'ghost' | 'raised' | The look of the field |
placeholder | string | Root's placeholder | Shows when no value is chosen |
color | AccentColor | Root's color | The colour |
radius | 'none' | 'small' | 'medium' | 'large' | 'full' | Theme radius | The corner |
width | string, responsive | The CSS width | |
error | boolean | false | Shows the invalid state and sets aria-invalid |
loading | boolean | false | Shows a spinner, sets aria-busy and doesn't open |
readOnly | boolean | false | Sets aria-readonly and doesn't open |
disabled | boolean | Root's disabled | Disables the trigger |
Trigger also accepts every attribute of the HTML button element. A single child element replaces the default layout of the value and the chevron. While loading, the spinner still shows after it.
Value
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | Root's placeholder | Shows when no value is chosen |
Content
| Prop | Type | Default | Description |
|---|---|---|---|
size | '1' | '2' | '3' | '4', responsive | Root's size | The height step of the panel |
color | AccentColor | Root's color | The colour of the highlight |
width, minWidth | string, responsive | The CSS width and minimum width | |
maxWidth | string, responsive | '480px' | The CSS maximum width |
height, minHeight, maxHeight | string, responsive | The CSS height limits | |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Where the panel opens |
align | 'start' | 'center' | 'end' | 'start' | How the panel lines up with the trigger |
sideOffset | number | 4 | The gap to the trigger in pixels |
container | HTMLElement | document.body | The element the portal renders into |
Input
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'raised' | 'surface' | 'soft' | 'ghost' | 'outline' | 'soft' | The look of the search box |
placeholder | string | Root's searchPlaceholder | Shows in the empty search box |
startAdornment, endAdornment | ReactNode | Content before or after the text, such as an icon |
Input also accepts the attributes of the HTML input element.
Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | The text of the children | The value this item sets |
label | string | The text of the children | The text that search matches |
keywords | string[] | More words that search matches | |
disabled | boolean | Root's disabled | Stops the item from being chosen |
onSelect | (value: string) => void | Called when this item is chosen |
Group accepts heading and forceMount. Separator accepts alwaysRender, which keeps it visible during a search. List accepts label, which defaults to the trigger's name. Each part also accepts the attributes of the HTML div element.
Accessibility
| Key | Action |
|---|---|
| Enter, Space, Arrow Down or Arrow Up | Opens the panel from the trigger |
| A letter key | Types into the search box and filters the list |
| Arrow Down or Arrow Up | Moves the highlight through the items |
| Home or End | Moves the highlight to the first or last item |
| Enter | Chooses the highlighted item and closes the panel |
| Escape | Closes the panel without changing the value |
- Name: The trigger needs a name from a
label,aria-labeloraria-labelledby. In development, a trigger without one logs a warning. The panel, the search box and the list take the same name. - Announcements: The trigger announces its name, the value and whether the panel is expanded. After typing pauses, a live region announces the number of results, or "No results".
- Selection: The highlighted item sets
aria-selected, and the chosen item setsaria-checked. Group labels name their groups. - Focus: Focus moves to the search box when the panel opens, and the highlight starts on the chosen item. Without an
Input, focus moves to the list so the keys still work. Focus returns to the trigger when the panel closes.