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

PropTypeDefaultDescription
valuestring | nullThe chosen value, when you control it
defaultValuestring | nullnullThe value at first render
onValueChange(value: string | null) => voidCalled when someone chooses an item
displayValuestring | (value) => string | undefinedThe text the trigger shows for the value. Pass a stable function
placeholderstring'Select an option'Shows in the trigger when no value is chosen
searchPlaceholderstring'Search...'Shows in the empty search box
searchValuestringThe search text, when you control it
defaultSearchValuestring''The search text at first render
onSearchValueChange(value: string) => voidCalled when the search text changes
shouldFilterbooleantrueFilters and sorts items by the search. Set false to filter yourself
filter(value, search, keywords) => numberYour own match score, from 0 (hidden) to 1
resetSearchOnSelectbooleantrueClears the search when an item is chosen
resetSearchOnClosebooleantrueClears the search when the panel closes without a choice
loopbooleantrueWraps the arrow keys from the last item to the first
openbooleanWhether the panel is open, when you control it
defaultOpenbooleanfalseWhether the panel is open at first render
onOpenChange(open: boolean) => voidCalled when the panel opens or closes
size'1' | '2' | '3' | '4', responsive'2'The height step
colorAccentColorTheme accentThe colour of the trigger and the panel
disabledbooleanfalseDisables the trigger and every item
modalbooleanfalseBlocks the rest of the page while the panel is open

Trigger

PropTypeDefaultDescription
variant'raised' | 'surface' | 'soft' | 'outline' | 'ghost''raised'The look of the field
placeholderstringRoot's placeholderShows when no value is chosen
colorAccentColorRoot's colorThe colour
radius'none' | 'small' | 'medium' | 'large' | 'full'Theme radiusThe corner
widthstring, responsiveThe CSS width
errorbooleanfalseShows the invalid state and sets aria-invalid
loadingbooleanfalseShows a spinner, sets aria-busy and doesn't open
readOnlybooleanfalseSets aria-readonly and doesn't open
disabledbooleanRoot's disabledDisables 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

PropTypeDefaultDescription
placeholderstringRoot's placeholderShows when no value is chosen

Content

PropTypeDefaultDescription
size'1' | '2' | '3' | '4', responsiveRoot's sizeThe height step of the panel
colorAccentColorRoot's colorThe colour of the highlight
width, minWidthstring, responsiveThe CSS width and minimum width
maxWidthstring, responsive'480px'The CSS maximum width
height, minHeight, maxHeightstring, responsiveThe 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
sideOffsetnumber4The gap to the trigger in pixels
containerHTMLElementdocument.bodyThe element the portal renders into

Input

PropTypeDefaultDescription
variant'raised' | 'surface' | 'soft' | 'ghost' | 'outline''soft'The look of the search box
placeholderstringRoot's searchPlaceholderShows in the empty search box
startAdornment, endAdornmentReactNodeContent before or after the text, such as an icon

Input also accepts the attributes of the HTML input element.

Item

PropTypeDefaultDescription
valuestringThe text of the childrenThe value this item sets
labelstringThe text of the childrenThe text that search matches
keywordsstring[]More words that search matches
disabledbooleanRoot's disabledStops the item from being chosen
onSelect(value: string) => voidCalled 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

KeyAction
Enter, Space, Arrow Down or Arrow UpOpens the panel from the trigger
A letter keyTypes into the search box and filters the list
Arrow Down or Arrow UpMoves the highlight through the items
Home or EndMoves the highlight to the first or last item
EnterChooses the highlighted item and closes the panel
EscapeCloses the panel without changing the value
  • Name: The trigger needs a name from a label, aria-label or aria-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 sets aria-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.