Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Combobox

An input combined with a list of predefined items to select.

Rendered by React 19.3.1

Loading React…
import { Combobox } from '@hamur/react/combobox'

const fruits = ['Apple', 'Banana', 'Cherry', 'Grape', 'Lemon', 'Mango', 'Orange', 'Peach', 'Pear']

export default function ComboboxExample() {
  return (
    <Combobox.Root items={fruits}>
      <label className="field">
        <span className="select-label">Choose a fruit</span>
        <Combobox.InputGroup className="combobox">
          <Combobox.Input className="combobox-input" placeholder="e.g. Apple" />
          <Combobox.Clear
            className="combobox-button"
            aria-label="Clear selection"
          >
            ×
          </Combobox.Clear>
          <Combobox.Trigger
            className="combobox-button"
            aria-label="Open popup"
          >
            <Combobox.Icon className="select-icon" />
          </Combobox.Trigger>
        </Combobox.InputGroup>
      </label>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={6}>
          <Combobox.Popup className="select-popup combobox-popup">
            <Combobox.Empty className="combobox-empty">
              No fruits found.
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(fruit: string) => (
                  <Combobox.Item
                    key={fruit}
                    value={fruit}
                    className="select-item"
                  >
                    <Combobox.ItemIndicator
                      className="select-item-indicator"
                    >
                      ✓
                    </Combobox.ItemIndicator>
                    <span className="select-item-text">{fruit}</span>
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}
2,150Tests passing in React · 0 failing
63.7 KiBSize
5.10 msOpen
18.0 msClose
43.0 msOpenMutations
43.0 msCloseMutations
0.99 MiBMemory
productdevbook and 40 upstream authors23 commits · last

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Combobox.Root>
  <Combobox.InputGroup>
    <Combobox.Input />
    <Combobox.Clear />
    <Combobox.Trigger>
      <Combobox.Icon />
    </Combobox.Trigger>
  </Combobox.InputGroup>
  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.Empty />
        <Combobox.List>
          <Combobox.Collection>
            <Combobox.Item>
              <Combobox.ItemIndicator />
            </Combobox.Item>
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

// Not in this example: Combobox.Arrow, Combobox.Backdrop, Combobox.Chip, Combobox.ChipRemove, Combobox.Chips, Combobox.Group, Combobox.GroupLabel, Combobox.Label, Combobox.Row, Combobox.Separator, Combobox.Status, Combobox.Value

Examples

Create Items

createItems derives each item's value and label when the app stores ids.

ComboboxCreateItems.tsx
import { Combobox } from '@hamur/react/combobox'

interface Fruit {
  id: string
  name: string
}

const fruits: Fruit[] = [
  { id: 'apple', name: 'Apple' },
  { id: 'banana', name: 'Banana' },
  { id: 'orange', name: 'Orange' },
  { id: 'pineapple', name: 'Pineapple' },
  { id: 'grape', name: 'Grape' },
  { id: 'mango', name: 'Mango' },
  { id: 'strawberry', name: 'Strawberry' },
  { id: 'blueberry', name: 'Blueberry' },
  { id: 'raspberry', name: 'Raspberry' },
  { id: 'blackberry', name: 'Blackberry' },
  { id: 'cherry', name: 'Cherry' },
  { id: 'peach', name: 'Peach' },
  { id: 'pear', name: 'Pear' },
  { id: 'plum', name: 'Plum' },
  { id: 'kiwi', name: 'Kiwi' },
  { id: 'watermelon', name: 'Watermelon' },
]

// The value is the id, the label the name.
const items = Combobox.createItems(fruits, {
  getValue: (fruit) => fruit.id,
  getLabel: (fruit) => fruit.name,
})

export default function ComboboxCreateItemsExample() {
  return (
    <Combobox.Root items={items} defaultValue="banana">
      <label className="field">
        <span className="select-label">Choose a fruit</span>
        <Combobox.InputGroup className="combobox">
          <Combobox.Input className="combobox-input" placeholder="e.g. Apple" />
          <Combobox.Clear
            className="combobox-button"
            aria-label="Clear selection"
          >
            ×
          </Combobox.Clear>
          <Combobox.Trigger
            className="combobox-button"
            aria-label="Open popup"
          >
            <Combobox.Icon className="select-icon" />
          </Combobox.Trigger>
        </Combobox.InputGroup>
      </label>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={6}>
          <Combobox.Popup className="select-popup combobox-popup">
            <Combobox.Empty className="combobox-empty">
              No fruits found.
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(item: Fruit) => (
                  <Combobox.Item
                    key={item.id}
                    value={item.id}
                    className="select-item"
                  >
                    <Combobox.ItemIndicator
                      className="select-item-indicator"
                    >
                      ✓
                    </Combobox.ItemIndicator>
                    <span className="select-item-text">{item.name}</span>
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

Multiple

multiple keeps several values, shown as chips in the input.

ComboboxMultiple.tsx
import { Combobox } from '@hamur/react/combobox'

interface ProgrammingLanguage {
  id: string
  value: string
}

const langs: ProgrammingLanguage[] = [
  { id: 'js', value: 'JavaScript' },
  { id: 'ts', value: 'TypeScript' },
  { id: 'py', value: 'Python' },
  { id: 'java', value: 'Java' },
  { id: 'cpp', value: 'C++' },
  { id: 'cs', value: 'C#' },
  { id: 'php', value: 'PHP' },
  { id: 'ruby', value: 'Ruby' },
  { id: 'go', value: 'Go' },
  { id: 'rust', value: 'Rust' },
  { id: 'swift', value: 'Swift' },
]

export default function ComboboxMultipleExample() {
  return (
    <Combobox.Root items={langs} multiple>
      <div className="field">
        <label className="select-label" htmlFor="combobox-languages">
          Programming languages
        </label>
        <Combobox.InputGroup className="combobox combobox-chips-group">
          <Combobox.Value>
            {(value: ProgrammingLanguage[]) => (
              <Combobox.Chips
                className="combobox-chips"
                aria-label={value.length > 0 ? 'Selected languages' : undefined}
              >
                {value.map((language) => (
                  <Combobox.Chip
                    key={language.id}
                    className="combobox-chip"
                    aria-label={language.value}
                    aria-description="Press Backspace or Delete to remove"
                  >
                    {language.value}
                    <Combobox.ChipRemove
                      className="combobox-chip-remove"
                      aria-label={`Remove ${language.value}`}
                    >
                      ×
                    </Combobox.ChipRemove>
                  </Combobox.Chip>
                ))}
                <Combobox.Input
                  id="combobox-languages"
                  className="combobox-input"
                  placeholder={value.length > 0 ? '' : 'e.g. TypeScript'}
                  aria-description={
                    value.length > 0
                      ? `${value.length} selected. From the start of the input, press Left Arrow to focus the selected items`
                      : undefined
                  }
                />
              </Combobox.Chips>
            )}
          </Combobox.Value>
        </Combobox.InputGroup>
      </div>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={6}>
          <Combobox.Popup className="select-popup combobox-popup">
            <Combobox.Empty className="combobox-empty">
              No languages found.
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(language: ProgrammingLanguage) => (
                  <Combobox.Item
                    key={language.id}
                    value={language}
                    className="select-item"
                  >
                    <Combobox.ItemIndicator
                      className="select-item-indicator"
                    >
                      ✓
                    </Combobox.ItemIndicator>
                    <span className="select-item-text">{language.value}</span>
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

Input Inside Popup

The input sits in the popup; Label names the trigger that opens it.

ComboboxInputInsidePopup.tsx
import { Combobox } from '@hamur/react/combobox'

interface Country {
  code: string
  value: string
  continent: string
  label: string
}

// A shorter list than upstream's demo, which has every country.
const countries: Country[] = [
  { code: 'ar', value: 'argentina', label: 'Argentina', continent: 'South America' },
  { code: 'au', value: 'australia', label: 'Australia', continent: 'Oceania' },
  { code: 'at', value: 'austria', label: 'Austria', continent: 'Europe' },
  { code: 'be', value: 'belgium', label: 'Belgium', continent: 'Europe' },
  { code: 'br', value: 'brazil', label: 'Brazil', continent: 'South America' },
  { code: 'ca', value: 'canada', label: 'Canada', continent: 'North America' },
  { code: 'cl', value: 'chile', label: 'Chile', continent: 'South America' },
  { code: 'cn', value: 'china', label: 'China', continent: 'Asia' },
  { code: 'dk', value: 'denmark', label: 'Denmark', continent: 'Europe' },
  { code: 'eg', value: 'egypt', label: 'Egypt', continent: 'Africa' },
  { code: 'fi', value: 'finland', label: 'Finland', continent: 'Europe' },
  { code: 'fr', value: 'france', label: 'France', continent: 'Europe' },
  { code: 'de', value: 'germany', label: 'Germany', continent: 'Europe' },
  { code: 'gr', value: 'greece', label: 'Greece', continent: 'Europe' },
  { code: 'in', value: 'india', label: 'India', continent: 'Asia' },
  { code: 'id', value: 'indonesia', label: 'Indonesia', continent: 'Asia' },
  { code: 'ie', value: 'ireland', label: 'Ireland', continent: 'Europe' },
  { code: 'it', value: 'italy', label: 'Italy', continent: 'Europe' },
  { code: 'jp', value: 'japan', label: 'Japan', continent: 'Asia' },
  { code: 'ke', value: 'kenya', label: 'Kenya', continent: 'Africa' },
  { code: 'mx', value: 'mexico', label: 'Mexico', continent: 'North America' },
  { code: 'nl', value: 'netherlands', label: 'Netherlands', continent: 'Europe' },
  { code: 'nz', value: 'new-zealand', label: 'New Zealand', continent: 'Oceania' },
  { code: 'ng', value: 'nigeria', label: 'Nigeria', continent: 'Africa' },
  { code: 'no', value: 'norway', label: 'Norway', continent: 'Europe' },
  { code: 'pe', value: 'peru', label: 'Peru', continent: 'South America' },
  { code: 'pl', value: 'poland', label: 'Poland', continent: 'Europe' },
  { code: 'pt', value: 'portugal', label: 'Portugal', continent: 'Europe' },
  { code: 'kr', value: 'south-korea', label: 'South Korea', continent: 'Asia' },
  { code: 'es', value: 'spain', label: 'Spain', continent: 'Europe' },
  { code: 'se', value: 'sweden', label: 'Sweden', continent: 'Europe' },
  { code: 'ch', value: 'switzerland', label: 'Switzerland', continent: 'Europe' },
  { code: 'tr', value: 'turkey', label: 'Turkey', continent: 'Asia' },
  { code: 'ua', value: 'ukraine', label: 'Ukraine', continent: 'Europe' },
  { code: 'gb', value: 'united-kingdom', label: 'United Kingdom', continent: 'Europe' },
  { code: 'us', value: 'united-states', label: 'United States', continent: 'North America' },
]

export default function ComboboxInputInsidePopupExample() {
  return (
    <div className="field">
      <Combobox.Root items={countries}>
        <Combobox.Label className="select-label">
          Country
        </Combobox.Label>
        <Combobox.Trigger className="select">
          <Combobox.Value placeholder="Select country" />
          <Combobox.Icon className="select-icon" />
        </Combobox.Trigger>
        <Combobox.Portal>
          <Combobox.Positioner
            className="select-positioner"
            align="start"
            sideOffset={6}
          >
            <Combobox.Popup className="select-popup" aria-label="Select country">
              <Combobox.Input
                className="input combobox-search"
                placeholder="e.g. United Kingdom"
              />
              <Combobox.Empty className="combobox-empty">
                No countries found.
              </Combobox.Empty>
              <Combobox.List className="select-list">
                <Combobox.Collection>
                  {(country: Country) => (
                    <Combobox.Item
                      key={country.code}
                      value={country}
                      className="select-item"
                    >
                      <Combobox.ItemIndicator
                        className="select-item-indicator"
                      >
                        ✓
                      </Combobox.ItemIndicator>
                      <span className="select-item-text">{country.label}</span>
                    </Combobox.Item>
                  )}
                </Combobox.Collection>
              </Combobox.List>
            </Combobox.Popup>
          </Combobox.Positioner>
        </Combobox.Portal>
      </Combobox.Root>
    </div>
  )
}

Grouped

Group and GroupLabel add headings inside the popup.

ComboboxGrouped.tsx
import { Combobox } from '@hamur/react/combobox'

interface Produce {
  id: string
  label: string
  group: 'Fruits' | 'Vegetables'
}

interface ProduceGroup {
  value: string
  items: Produce[]
}

const produceData: Produce[] = [
  { id: 'fruit-apple', label: 'Apple', group: 'Fruits' },
  { id: 'fruit-banana', label: 'Banana', group: 'Fruits' },
  { id: 'fruit-mango', label: 'Mango', group: 'Fruits' },
  { id: 'fruit-kiwi', label: 'Kiwi', group: 'Fruits' },
  { id: 'fruit-grape', label: 'Grape', group: 'Fruits' },
  { id: 'fruit-orange', label: 'Orange', group: 'Fruits' },
  { id: 'fruit-strawberry', label: 'Strawberry', group: 'Fruits' },
  { id: 'fruit-watermelon', label: 'Watermelon', group: 'Fruits' },
  { id: 'veg-broccoli', label: 'Broccoli', group: 'Vegetables' },
  { id: 'veg-carrot', label: 'Carrot', group: 'Vegetables' },
  { id: 'veg-cauliflower', label: 'Cauliflower', group: 'Vegetables' },
  { id: 'veg-cucumber', label: 'Cucumber', group: 'Vegetables' },
  { id: 'veg-kale', label: 'Kale', group: 'Vegetables' },
  { id: 'veg-pepper', label: 'Bell pepper', group: 'Vegetables' },
  { id: 'veg-spinach', label: 'Spinach', group: 'Vegetables' },
  { id: 'veg-zucchini', label: 'Zucchini', group: 'Vegetables' },
]

function groupProduce(items: Produce[]): ProduceGroup[] {
  const groups: Record<string, Produce[]> = {}
  for (const item of items) (groups[item.group] ??= []).push(item)
  return ['Fruits', 'Vegetables'].map((value) => ({ value, items: groups[value] ?? [] }))
}

const groupedProduce = groupProduce(produceData)

export default function ComboboxGroupedExample() {
  return (
    <Combobox.Root items={groupedProduce}>
      <label className="field">
        <span className="select-label">Select produce</span>
        <Combobox.InputGroup className="combobox">
          <Combobox.Input className="combobox-input" placeholder="e.g. Mango" />
          <Combobox.Clear
            className="combobox-button"
            aria-label="Clear selection"
          >
            ×
          </Combobox.Clear>
          <Combobox.Trigger
            className="combobox-button"
            aria-label="Open popup"
          >
            <Combobox.Icon className="select-icon" />
          </Combobox.Trigger>
        </Combobox.InputGroup>
      </label>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={6}>
          <Combobox.Popup className="select-popup combobox-popup">
            <Combobox.Empty className="combobox-empty">
              No produce found.
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(group: ProduceGroup) => (
                  <Combobox.Group
                    key={group.value}
                    items={group.items}
                    className="select-group"
                  >
                    <Combobox.GroupLabel className="select-group-label">
                      {group.value}
                    </Combobox.GroupLabel>
                    <Combobox.Collection>
                      {(item: Produce) => (
                        <Combobox.Item
                          key={item.id}
                          value={item}
                          className="select-item"
                        >
                          <Combobox.ItemIndicator
                            className="select-item-indicator"
                          >
                            ✓
                          </Combobox.ItemIndicator>
                          <span className="select-item-text">{item.label}</span>
                        </Combobox.Item>
                      )}
                    </Combobox.Collection>
                  </Combobox.Group>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

Async Single

Items load from a remote source as the person types.

ComboboxAsyncSingle.tsx
import * as React from 'react'
import { Combobox } from '@hamur/react/combobox'

interface DirectoryUser {
  id: string
  name: string
  username: string
  email: string
  title: string
}

export default function ComboboxAsyncSingleExample() {
  const [searchResults, setSearchResults] = React.useState<DirectoryUser[]>([])
  const [selectedValue, setSelectedValue] = React.useState<DirectoryUser | null>(null)
  const [searchValue, setSearchValue] = React.useState('')
  const [error, setError] = React.useState<string | null>(null)
  const [isPending, startTransition] = React.useTransition()

  const { contains } = Combobox.useFilter()

  const abortControllerRef = React.useRef<AbortController | null>(null)

  const trimmedSearchValue = searchValue.trim()

  // The selected person stays an item so the input can show it.
  const items = React.useMemo(() => {
    if (!selectedValue || searchResults.some((user) => user.id === selectedValue.id)) {
      return searchResults
    }
    return [...searchResults, selectedValue]
  }, [searchResults, selectedValue])

  function getStatus() {
    if (isPending) {
      return (
        <React.Fragment>
          <span className="combobox-spinner" aria-hidden />
          Searching…
        </React.Fragment>
      )
    }
    if (error) {
      return error
    }
    if (trimmedSearchValue === '') {
      return selectedValue ? null : 'Start typing to search people…'
    }
    if (searchResults.length === 0) {
      return `No matches for "${trimmedSearchValue}".`
    }
    return null
  }

  function getEmptyMessage() {
    if (trimmedSearchValue === '' || isPending || searchResults.length > 0 || error) {
      return null
    }
    return 'Try a different search term.'
  }

  const status = getStatus()
  const emptyMessage = getEmptyMessage()

  return (
    <Combobox.Root
      items={items}
      itemToStringLabel={(user: DirectoryUser) => user.name}
      isItemEqualToValue={(item: DirectoryUser, value: DirectoryUser) => item.id === value.id}
      filter={null}
      onOpenChangeComplete={(open) => {
        if (!open && selectedValue) {
          setSearchResults([selectedValue])
        }
      }}
      onValueChange={(nextSelectedValue: DirectoryUser | null) => {
        setSelectedValue(nextSelectedValue)
        setSearchValue('')
        setError(null)
      }}
      onInputValueChange={(nextSearchValue, { reason }) => {
        setSearchValue(nextSearchValue)

        const controller = new AbortController()
        abortControllerRef.current?.abort()
        abortControllerRef.current = controller

        if (nextSearchValue === '') {
          setSearchResults([])
          setError(null)
          return
        }

        if (reason === 'item-press') {
          return
        }

        startTransition(async () => {
          setError(null)
          const result = await searchUsers(nextSearchValue, contains)
          if (controller.signal.aborted) {
            return
          }
          startTransition(() => {
            setSearchResults(result.users)
            setError(result.error)
          })
        })
      }}
    >
      <div className="field">
        <label className="select-label" htmlFor="combobox-async-single">
          Assign reviewer
        </label>
        <Combobox.InputGroup className="combobox">
          <Combobox.Input
            id="combobox-async-single"
            className="combobox-input"
            placeholder="e.g. Michael"
          />
          <Combobox.Clear
            className="combobox-button"
            aria-label="Clear selection"
          >
            ×
          </Combobox.Clear>
          <Combobox.Trigger
            className="combobox-button"
            aria-label="Open popup"
          >
            <Combobox.Icon className="select-icon" />
          </Combobox.Trigger>
        </Combobox.InputGroup>
      </div>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={4}>
          <Combobox.Popup
            className="select-popup combobox-popup"
            aria-busy={isPending || undefined}
          >
            <Combobox.Status>
              {status ? <div className="combobox-status">{status}</div> : null}
            </Combobox.Status>
            <Combobox.Empty>
              {emptyMessage ? <div className="combobox-empty">{emptyMessage}</div> : null}
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(user: DirectoryUser) => (
                  <Combobox.Item
                    key={user.id}
                    value={user}
                    className="select-item"
                  >
                    <Combobox.ItemIndicator
                      className="select-item-indicator"
                    >
                      ✓
                    </Combobox.ItemIndicator>
                    <span className="select-item-text combobox-person">
                      <span>{user.name}</span>
                      <span className="combobox-person-detail">{user.email}</span>
                      <span className="combobox-person-detail">
                        <span>@{user.username}</span>
                        <span>{user.title}</span>
                      </span>
                    </span>
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

async function searchUsers(
  query: string,
  filter: (item: string, query: string) => boolean,
): Promise<{ users: DirectoryUser[]; error: string | null }> {
  // A pretend network delay.
  await new Promise((resolve) => {
    setTimeout(resolve, Math.random() * 500 + 100)
  })
  // A pretend network error, 1% of the time or for this query.
  if (Math.random() < 0.01 || query === 'will_error') {
    return { users: [], error: 'Failed to fetch people. Please try again.' }
  }
  const users = allUsers.filter(
    (user) =>
      filter(user.name, query) ||
      filter(user.username, query) ||
      filter(user.email, query) ||
      filter(user.title, query),
  )
  return { users, error: null }
}

const allUsers: DirectoryUser[] = [
  {
    id: 'leslie-alexander',
    name: 'Leslie Alexander',
    username: 'leslie',
    email: 'leslie.alexander@example.com',
    title: 'Product Manager',
  },
  {
    id: 'kathryn-murphy',
    name: 'Kathryn Murphy',
    username: 'kathryn',
    email: 'kathryn.murphy@example.com',
    title: 'Marketing Lead',
  },
  {
    id: 'courtney-henry',
    name: 'Courtney Henry',
    username: 'courtney',
    email: 'courtney.henry@example.com',
    title: 'Design Systems',
  },
  {
    id: 'michael-foster',
    name: 'Michael Foster',
    username: 'michael',
    email: 'michael.foster@example.com',
    title: 'Engineering Manager',
  },
  {
    id: 'lindsay-walton',
    name: 'Lindsay Walton',
    username: 'lindsay',
    email: 'lindsay.walton@example.com',
    title: 'Product Designer',
  },
  {
    id: 'tom-cook',
    name: 'Tom Cook',
    username: 'tom',
    email: 'tom.cook@example.com',
    title: 'Frontend Engineer',
  },
  {
    id: 'whitney-francis',
    name: 'Whitney Francis',
    username: 'whitney',
    email: 'whitney.francis@example.com',
    title: 'Customer Success',
  },
  {
    id: 'jacob-jones',
    name: 'Jacob Jones',
    username: 'jacob',
    email: 'jacob.jones@example.com',
    title: 'Security Engineer',
  },
  {
    id: 'arlene-mccoy',
    name: 'Arlene McCoy',
    username: 'arlene',
    email: 'arlene.mccoy@example.com',
    title: 'Data Analyst',
  },
  {
    id: 'marvin-mckinney',
    name: 'Marvin McKinney',
    username: 'marvin',
    email: 'marvin.mckinney@example.com',
    title: 'QA Specialist',
  },
  {
    id: 'eleanor-pena',
    name: 'Eleanor Pena',
    username: 'eleanor',
    email: 'eleanor.pena@example.com',
    title: 'Operations',
  },
  {
    id: 'jerome-bell',
    name: 'Jerome Bell',
    username: 'jerome',
    email: 'jerome.bell@example.com',
    title: 'DevOps Engineer',
  },
]

Async Multiple

Remote items with multiple; chosen items stay in the list.

ComboboxAsyncMultiple.tsx
import * as React from 'react'
import { Combobox } from '@hamur/react/combobox'

interface DirectoryUser {
  id: string
  name: string
  username: string
  email: string
  title: string
}

export default function ComboboxAsyncMultipleExample() {
  const [searchResults, setSearchResults] = React.useState<DirectoryUser[]>([])
  const [selectedValues, setSelectedValues] = React.useState<DirectoryUser[]>([])
  const [searchValue, setSearchValue] = React.useState('')
  const [error, setError] = React.useState<string | null>(null)
  const [blockStartStatus, setBlockStartStatus] = React.useState(false)

  const [isPending, startTransition] = React.useTransition()

  const { contains } = Combobox.useFilter()

  const abortControllerRef = React.useRef<AbortController | null>(null)
  const selectedValuesRef = React.useRef<DirectoryUser[]>([])

  const trimmedSearchValue = searchValue.trim()

  // The selected people stay items so their chips keep a label.
  const items = React.useMemo(() => {
    if (selectedValues.length === 0) {
      return searchResults
    }
    const merged = [...searchResults]
    selectedValues.forEach((user) => {
      if (!searchResults.some((result) => result.id === user.id)) {
        merged.push(user)
      }
    })
    return merged
  }, [searchResults, selectedValues])

  function getStatus() {
    if (isPending) {
      return (
        <React.Fragment>
          <span className="combobox-spinner" aria-hidden />
          Searching…
        </React.Fragment>
      )
    }
    if (error) {
      return error
    }
    if (trimmedSearchValue === '' && !blockStartStatus) {
      return selectedValues.length > 0 ? null : 'Start typing to search people…'
    }
    if (searchResults.length === 0 && !blockStartStatus) {
      return `No matches for "${trimmedSearchValue}".`
    }
    return null
  }

  function getEmptyMessage() {
    if (trimmedSearchValue === '' || isPending || searchResults.length > 0 || error) {
      return null
    }
    return 'Try a different search term.'
  }

  const status = getStatus()
  const emptyMessage = getEmptyMessage()

  return (
    <Combobox.Root
      items={items}
      itemToStringLabel={(user: DirectoryUser) => user.name}
      isItemEqualToValue={(item: DirectoryUser, value: DirectoryUser) => item.id === value.id}
      multiple
      filter={null}
      onOpenChangeComplete={(open) => {
        if (!open) {
          setSearchResults(selectedValuesRef.current)
          setBlockStartStatus(false)
        }
      }}
      onValueChange={(nextSelectedValues: DirectoryUser[]) => {
        selectedValuesRef.current = nextSelectedValues
        setSelectedValues(nextSelectedValues)
        setSearchValue('')
        setError(null)
        if (nextSelectedValues.length === 0) {
          setSearchResults([])
          setBlockStartStatus(false)
        } else {
          setBlockStartStatus(true)
        }
      }}
      onInputValueChange={(nextSearchValue, { reason }) => {
        setSearchValue(nextSearchValue)

        const controller = new AbortController()
        abortControllerRef.current?.abort()
        abortControllerRef.current = controller

        if (nextSearchValue === '') {
          setSearchResults(selectedValuesRef.current)
          setError(null)
          setBlockStartStatus(false)
          return
        }

        if (reason === 'item-press') {
          return
        }

        startTransition(async () => {
          setError(null)
          const result = await searchUsers(nextSearchValue, contains)
          if (controller.signal.aborted) {
            return
          }
          startTransition(() => {
            setSearchResults(result.users)
            setError(result.error)
          })
        })
      }}
    >
      <div className="field">
        <label className="select-label" htmlFor="combobox-async-multiple">
          Assign reviewers
        </label>
        <Combobox.InputGroup className="combobox combobox-chips-group">
          <Combobox.Value>
            {(value: DirectoryUser[]) => (
              <Combobox.Chips
                className="combobox-chips"
                aria-label={value.length > 0 ? 'Selected reviewers' : undefined}
              >
                {value.map((user) => (
                  <Combobox.Chip
                    key={user.id}
                    className="combobox-chip"
                    aria-label={user.name}
                    aria-description="Press Backspace or Delete to remove"
                  >
                    {user.name}
                    <Combobox.ChipRemove
                      className="combobox-chip-remove"
                      aria-label={`Remove ${user.name}`}
                    >
                      ×
                    </Combobox.ChipRemove>
                  </Combobox.Chip>
                ))}
                <Combobox.Input
                  id="combobox-async-multiple"
                  className="combobox-input"
                  placeholder={value.length > 0 ? '' : 'e.g. Michael'}
                  aria-description={
                    value.length > 0
                      ? `${value.length} selected. From the start of the input, press Left Arrow to focus the selected items`
                      : undefined
                  }
                />
              </Combobox.Chips>
            )}
          </Combobox.Value>
        </Combobox.InputGroup>
      </div>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={4}>
          <Combobox.Popup
            className="select-popup combobox-popup"
            aria-busy={isPending || undefined}
          >
            <Combobox.Status>
              {status ? <div className="combobox-status">{status}</div> : null}
            </Combobox.Status>
            <Combobox.Empty>
              {emptyMessage ? <div className="combobox-empty">{emptyMessage}</div> : null}
            </Combobox.Empty>
            <Combobox.List className="select-list">
              <Combobox.Collection>
                {(user: DirectoryUser) => (
                  <Combobox.Item
                    key={user.id}
                    value={user}
                    className="select-item"
                  >
                    <Combobox.ItemIndicator
                      className="select-item-indicator"
                    >
                      ✓
                    </Combobox.ItemIndicator>
                    <span className="select-item-text combobox-person">
                      <span>{user.name}</span>
                      <span className="combobox-person-detail">{user.email}</span>
                      <span className="combobox-person-detail">
                        <span>@{user.username}</span>
                        <span>{user.title}</span>
                      </span>
                    </span>
                  </Combobox.Item>
                )}
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

async function searchUsers(
  query: string,
  filter: (item: string, query: string) => boolean,
): Promise<{ users: DirectoryUser[]; error: string | null }> {
  // A pretend network delay.
  await new Promise((resolve) => {
    setTimeout(resolve, Math.random() * 500 + 100)
  })
  // A pretend network error, 1% of the time or for this query.
  if (Math.random() < 0.01 || query === 'will_error') {
    return { users: [], error: 'Failed to fetch people. Please try again.' }
  }
  const users = allUsers.filter(
    (user) =>
      filter(user.name, query) ||
      filter(user.username, query) ||
      filter(user.email, query) ||
      filter(user.title, query),
  )
  return { users, error: null }
}

const allUsers: DirectoryUser[] = [
  {
    id: 'leslie-alexander',
    name: 'Leslie Alexander',
    username: 'leslie',
    email: 'leslie.alexander@example.com',
    title: 'Product Manager',
  },
  {
    id: 'kathryn-murphy',
    name: 'Kathryn Murphy',
    username: 'kathryn',
    email: 'kathryn.murphy@example.com',
    title: 'Marketing Lead',
  },
  {
    id: 'courtney-henry',
    name: 'Courtney Henry',
    username: 'courtney',
    email: 'courtney.henry@example.com',
    title: 'Design Systems',
  },
  {
    id: 'michael-foster',
    name: 'Michael Foster',
    username: 'michael',
    email: 'michael.foster@example.com',
    title: 'Engineering Manager',
  },
  {
    id: 'lindsay-walton',
    name: 'Lindsay Walton',
    username: 'lindsay',
    email: 'lindsay.walton@example.com',
    title: 'Product Designer',
  },
  {
    id: 'tom-cook',
    name: 'Tom Cook',
    username: 'tom',
    email: 'tom.cook@example.com',
    title: 'Frontend Engineer',
  },
  {
    id: 'whitney-francis',
    name: 'Whitney Francis',
    username: 'whitney',
    email: 'whitney.francis@example.com',
    title: 'Customer Success',
  },
  {
    id: 'jacob-jones',
    name: 'Jacob Jones',
    username: 'jacob',
    email: 'jacob.jones@example.com',
    title: 'Security Engineer',
  },
  {
    id: 'arlene-mccoy',
    name: 'Arlene McCoy',
    username: 'arlene',
    email: 'arlene.mccoy@example.com',
    title: 'Data Analyst',
  },
  {
    id: 'marvin-mckinney',
    name: 'Marvin McKinney',
    username: 'marvin',
    email: 'marvin.mckinney@example.com',
    title: 'QA Specialist',
  },
  {
    id: 'eleanor-pena',
    name: 'Eleanor Pena',
    username: 'eleanor',
    email: 'eleanor.pena@example.com',
    title: 'Operations',
  },
  {
    id: 'jerome-bell',
    name: 'Jerome Bell',
    username: 'jerome',
    email: 'jerome.bell@example.com',
    title: 'DevOps Engineer',
  },
]

Virtualized

A virtualization library renders only the visible items of a long list.

ComboboxVirtualized.tsx
import * as React from 'react'
import { Combobox } from '@hamur/react/combobox'

interface VirtualizedItem {
  id: string
  name: string
}

// Upstream windows the list with @tanstack/react-virtual; this one does the same by hand with
// rows of a fixed height.
const ROW = 32
const PAD = 4
const VIEWPORT = 260
const OVERSCAN = 20

interface Scroller {
  scrollToIndex: (index: number, align: 'start' | 'end') => void
  count: number
}

export default function ComboboxVirtualizedExample() {
  const scrollerRef = React.useRef<Scroller | null>(null)

  return (
    <Combobox.Root
      virtualized
      items={virtualizedItems}
      itemToStringLabel={getItemLabel}
      onItemHighlighted={(item, { reason, index }) => {
        const scroller = scrollerRef.current
        if (!item || !scroller) {
          return
        }
        const isStart = index === 0
        const isEnd = index === scroller.count - 1
        const shouldScroll = reason === 'none' || (reason === 'keyboard' && (isStart || isEnd))
        if (shouldScroll) {
          queueMicrotask(() => {
            scroller.scrollToIndex(index, isEnd ? 'start' : 'end')
          })
        }
      }}
    >
      <label className="field">
        <span className="select-label">Search 10,000 items</span>
        <Combobox.Input className="input" />
      </label>
      <Combobox.Portal>
        <Combobox.Positioner className="select-positioner" sideOffset={4}>
          <Combobox.Popup className="select-popup combobox-popup">
            <Combobox.Empty>
              <div className="combobox-empty">No items found.</div>
            </Combobox.Empty>
            <Combobox.List>
              <VirtualizedList scrollerRef={scrollerRef} />
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  )
}

function VirtualizedList({ scrollerRef }: { scrollerRef: React.RefObject<Scroller | null> }) {
  const filteredItems = Combobox.useFilteredItems<VirtualizedItem>()
  const elementRef = React.useRef<HTMLDivElement | null>(null)
  const [scrollTop, setScrollTop] = React.useState(0)

  const count = filteredItems.length
  const totalSize = count * ROW + PAD * 2

  React.useImperativeHandle(
    scrollerRef,
    () => ({
      count,
      scrollToIndex(index, align) {
        const element = elementRef.current
        if (!element) return
        const start = PAD + index * ROW
        element.scrollTop =
          align === 'start' ? start - PAD : start + ROW + PAD - element.clientHeight
      },
    }),
    [count],
  )

  if (!count) {
    return null
  }

  const first = Math.max(0, Math.floor((scrollTop - PAD) / ROW) - OVERSCAN)
  const last = Math.min(count, Math.ceil((scrollTop + VIEWPORT) / ROW) + OVERSCAN)
  const rows: number[] = []
  for (let index = first; index < last; index += 1) {
    rows.push(index)
  }

  return (
    <div
      role="presentation"
      ref={elementRef}
      className="select-list combobox-scroller"
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
    >
      <div role="presentation" className="combobox-virtual" style={{ height: totalSize }}>
        {rows.map((index) => {
          const item = filteredItems[index]
          return (
            <Combobox.Item
              key={item.id}
              index={index}
              data-index={index}
              value={item}
              className="select-item"
              aria-setsize={count}
              aria-posinset={index + 1}
              style={{ top: 0, transform: `translateY(${PAD + index * ROW}px)` }}
            >
              <Combobox.ItemIndicator className="select-item-indicator">
                ✓
              </Combobox.ItemIndicator>
              <span className="select-item-text">{item.name}</span>
            </Combobox.Item>
          )
        })}
      </div>
    </div>
  )
}

function getItemLabel(item: VirtualizedItem | null) {
  return item ? item.name : ''
}

const virtualizedItems: VirtualizedItem[] = Array.from({ length: 10000 }, (_, index) => {
  const id = String(index + 1)
  const indexLabel = id.padStart(4, '0')
  return { id, name: `Item ${indexLabel}` }
})

API reference

76 props across 26 parts, in React syntax. className, style, render are listed once, under Shared props.

ArrowCombobox.Arrow

Displays an element positioned against the anchor. Renders a <div>.

Arrow data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-uncenteredPresent when the arrow is uncentered.

BackdropCombobox.Backdrop

An overlay displayed beneath the popup. Renders a <div>.

Backdrop data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the popup begins animating in.
data-ending-stylePresent when the popup is animating out.

ChipCombobox.Chip

An individual chip that represents a value in a multiselectable input. Renders a <div>.

ChipRemoveCombobox.ChipRemove

A button to remove a chip. Renders a <button>.

ChipRemove props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue

ChipsCombobox.Chips

A container for the chips in a multiselectable input. Renders a <div>.

ClearCombobox.Clear

Clears the value when clicked. Renders a <button>.

Clear props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
disabledWhether the component should ignore user interaction.booleanfalse
keepMountedWhether the component should remain mounted in the DOM when not visible.booleanfalse
Clear data attributes
Data attributeDescription
data-popup-openPresent when the corresponding popup is open.
data-disabledPresent when the button is disabled.
data-visiblePresent when the clear button is visible.
data-starting-stylePresent when the button begins animating in.
data-ending-stylePresent when the button is animating out.

CollectionCombobox.Collection

Renders filtered list items. If rendering a flat list, pass a function child to the List component instead, which implicitly wraps it. Doesn’t render its own element.

Collection props
PropTypeDefault
childrenrequired(item: any, index: number) => React.ReactNode—

EmptyCombobox.Empty

Renders its children only when the list is empty. Requires the items prop on the root component. Announces changes politely to screen readers. This component's root element must remain mounted in the DOM to announce changes consistently across screen readers. Avoid hiding or removing the component itself with display: none, hidden, aria-hidden, or conditional rendering. Prefer updating or conditionally rendering its children instead. Renders a <div>.

GroupCombobox.Group

Groups related items with the corresponding label. Renders a <div>.

Group props
PropTypeDefault
itemsItems to be rendered within this group.readonly any[]—

GroupLabelCombobox.GroupLabel

An accessible label that is automatically associated with its parent group. Renders a <div>.

IconCombobox.Icon

An icon that indicates that the trigger button opens the popup. Renders a <span>.

InputCombobox.Input

A text input to search for items in the list. Renders an <input>.

Input props
PropTypeDefault
disabledWhether the component should ignore user interaction.booleanfalse
Input data attributes
Data attributeDescription
data-popup-openPresent when the corresponding popup is open.
data-pressedPresent when the input is pressed.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is readonly.
data-popup-sideIndicates which side the corresponding popup is positioned relative to its anchor.
data-requiredPresent when the component is required.
data-validPresent when the component is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the component is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the component has been touched (when wrapped in Field.Root).
data-dirtyPresent when the component's value has changed (when wrapped in Field.Root).
data-filledPresent when the component has a value (when wrapped in Field.Root).
data-focusedPresent when the input is focused (when wrapped in Field.Root).
data-list-emptyPresent when the corresponding items list is empty.

InputGroupCombobox.InputGroup

A wrapper for the input and its associated controls. Renders a <div>.

InputGroup data attributes
Data attributeDescription
data-popup-openPresent when the corresponding popup is open.
data-pressedPresent when the input group is pressed.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is readonly.
data-popup-sideIndicates which side the corresponding popup is positioned relative to its anchor.
data-validPresent when the component is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the component is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the component has been touched (when wrapped in Field.Root).
data-dirtyPresent when the component's value has changed (when wrapped in Field.Root).
data-filledPresent when the component has a value (when wrapped in Field.Root).
data-focusedPresent when the component is focused (when wrapped in Field.Root).
data-list-emptyPresent when the corresponding items list is empty.
data-placeholderPresent when the combobox doesn't have a value.

ItemCombobox.Item

An individual item in the list. Renders a <div>.

Item props
PropTypeDefault
valueA unique value that identifies this item.anynull
onClickAn optional click handler for the item when selected.((event: BaseUIEvent<React.MouseEvent<HTMLDivElement, MouseEvent>>) => void)—
indexThe index of the item in the list.number—
nativeButtonWhether the component renders a native <button> element when replacing itbooleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
childrenReact.ReactNode—
Item data attributes
Data attributeDescription
data-selectedPresent when the item is selected.
data-highlightedPresent when the item is highlighted.
data-disabledPresent when the item is disabled.

ItemIndicatorCombobox.ItemIndicator

Indicates whether the item is selected. Renders a <span>.

ItemIndicator props
PropTypeDefault
childrenReact.ReactNode—
keepMountedWhether to keep the HTML element in the DOM when the item is not selected.booleanfalse
ItemIndicator data attributes
Data attributeDescription
data-starting-stylePresent when the indicator begins animating in.
data-ending-stylePresent when the indicator is animating out.

LabelCombobox.Label

An accessible label that is automatically associated with the combobox trigger. Renders a <div>.

ListCombobox.List

A list container for the items. Renders a <div>.

List props
PropTypeDefault
childrenReact.ReactNode | ((item: any, index: number) => React.ReactNode)—

PopupCombobox.Popup

A container for the list. Renders a <div>.

Popup props
PropTypeDefault
initialFocusDetermines the element to focus when the popup is opened.boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null)—
finalFocusDetermines the element to focus when the popup is closed.boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)—
Popup data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the popup begins animating in.
data-ending-stylePresent when the popup is animating out.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-anchor-hiddenPresent when the anchor is hidden.
data-emptyPresent when the items list is empty.

PortalCombobox.Portal

A portal element that moves the popup to a different part of the DOM. By default, the portal element is appended to <body>. Renders a <div>.

Portal props
PropTypeDefault
containerA parent element to render the portal element into.HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null—
keepMountedWhether to keep the portal mounted in the DOM while the popup is hidden.booleanfalse

PositionerCombobox.Positioner

Positions the popup against the trigger. Renders a <div>.

Positioner props
PropTypeDefault
disableAnchorTrackingWhether to disable the popup from tracking any layout shift of its positioning anchor.booleanfalse
alignHow to align the popup relative to the specified side.Align'center'
alignOffsetAdditional offset along the alignment axis in pixels.number | OffsetFunction0
sideWhich side of the anchor element to align the popup against.Side'bottom'
sideOffsetDistance between the anchor and the popup in pixels.number | OffsetFunction0
arrowPaddingMinimum distance to maintain between the arrow and the edges of the popup.number5
anchorAn element to position the popup against.Element | VirtualElement | React.RefObject<Element | null> | (() => Element | VirtualElement | null) | null—
collisionAvoidanceDetermines how to handle collisions when positioning the popup.CollisionAvoidance—
collisionBoundaryAn element or a rectangle that delimits the area that the popup is confined to.Boundary'clipping-ancestors'
collisionPaddingAdditional space to maintain from the edge of the collision boundary.Padding5
stickyWhether to maintain the popup in the viewport afterbooleanfalse
positionMethodDetermines which CSS position property to use."absolute" | "fixed"'absolute'
Positioner data attributes
Data attributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-anchor-hiddenPresent when the anchor is hidden.
data-sideIndicates which side the popup is positioned relative to the trigger.
data-alignIndicates how the popup is aligned relative to specified side.
data-emptyPresent when the items list is empty.
Positioner CSS variables
CSS variableDescription
--available-widthThe available width between the trigger and the edge of the viewport.
--available-heightThe available height between the trigger and the edge of the viewport.
--anchor-widthThe anchor's width.
--anchor-heightThe anchor's height.
--transform-originThe coordinates that this element is anchored to. Used for animations and transitions.

RootCombobox.Root

Groups all parts of the combobox. Doesn’t render its own element.

Root props
PropTypeDefault
nameIdentifies the field when a form is submitted.string—
defaultValueThe uncontrolled selected value of the combobox when it's initially rendered.ComboboxInputValue<Value, Multiple> | null—
valueThe selected value of the combobox.ComboboxInputValue<Value, Multiple> | null—
onValueChangeEvent handler called when the selected value of the combobox changes.((value: ComboboxOutputValue<Value, Multiple> | (Multiple extends true ? never : null), eventDetails: AriaCombobox.ChangeEventDetails) => void)—
defaultInputValueThe uncontrolled input value when initially rendered.string | number | readonly string[]—
inputValueThe input value of the combobox.string | number | readonly string[]—
onInputValueChangeEvent handler called when the input value changes.((inputValue: string, eventDetails: AriaCombobox.ChangeEventDetails) => void)—
defaultOpenWhether the popup is initially open.booleanfalse
openWhether the popup is currently open.boolean—
onOpenChangeEvent handler called when the popup is opened or closed.((open: boolean, eventDetails: AriaCombobox.OpenChangeEventDetails) => void)—
autoHighlightWhether the first matching item is highlighted automatically while filtering.booleanfalse
highlightItemOnHoverWhether moving the pointer over items should highlight them.booleantrue
actionsRefA ref to imperative actions.React.RefObject<AriaCombobox.Actions | null>—
autoCompleteProvides a hint to the browser for autofill.string—
filterFilter function used to match items vs input query.((item: Item, query: string, itemToString?: ((item: Item) => string) | undefined) => boolean) | null—
filteredItemsFiltered items to display in the list.readonly Item[] | readonly Group<Item>[]—
formIdentifies the form that owns the internal input.string—
gridWhether list items are presented in a grid layout.booleanfalse
inlineWhether the list is rendered inline without using the component's own popup.booleanfalse
isItemEqualToValueCustom comparison logic used to determine if a combobox item value matches the current selected value.((itemValue: Value, value: Value) => boolean)—
itemToStringLabelWhen the item values are objects (<Combobox.Item value={object}>), this function converts the object value to a string representation for display in the input.((itemValue: Value) => string)—
itemToStringValueWhen the item values are objects (<Combobox.Item value={object}>), this function converts the object value to a string representation for form submission.((itemValue: Value) => string)—
itemsThe items to be displayed in the list.readonly any[] | readonly Group<any>[] | ComboboxItemCollection<Item, Value>—
limitThe maximum number of items to display in the list.number-1
localeThe locale to use for string comparison.Intl.LocalesArgument—
loopFocusWhether to loop keyboard focus back to the input when the end of the list is reached while using the arrow keys.booleantrue
modalDetermines if the popup enters a modal state when open.booleanfalse
multipleWhether multiple items can be selected.Multiplefalse
onItemHighlightedCallback fired when an item is highlighted or unhighlighted.((highlightedValue: Value | undefined, eventDetails: AriaCombobox.HighlightEventDetails) => void)—
onOpenChangeCompleteEvent handler called after any animations complete when the popup is opened or closed.((open: boolean) => void)—
openOnInputClickWhether the popup opens when clicking the input.booleantrue
virtualizedWhether the items are being externally virtualized.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to choose a different option from the popup.booleanfalse
requiredWhether the user must choose a value before submitting a form.booleanfalse
inputRefA ref to the hidden input element.React.Ref<HTMLInputElement>—
idThe id of the component.string—
childrenReact.ReactNode—
isItemDisabledWhether the item at index is disabled, for items that are not mounted (a virtualized list).((itemValue: Value, index: number) => boolean)—

RowCombobox.Row

Displays a single row of items in a grid list. Enable grid on the root component to turn the listbox into a grid. Renders a <div>.

SeparatorCombobox.Separator

A visual separator between items or groups. Renders a <div>.

Separator props
PropTypeDefault
orientationThe orientation of the separator.Orientation'horizontal'
Separator data attributes
Data attributeDescription
data-orientationIndicates the orientation of the separator.

StatusCombobox.Status

Displays a status message whose content changes are announced politely to screen readers. Useful for conveying the status of an asynchronously loaded list. This component's root element must remain mounted in the DOM to announce changes consistently across screen readers. Avoid hiding or removing the component itself with display: none, hidden, aria-hidden, or conditional rendering. Prefer updating or conditionally rendering its children instead. Renders a <div>.

TriggerCombobox.Trigger

A button that opens the popup. Renders a <button>.

Trigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
disabledWhether the component should ignore user interaction.booleanfalse
Trigger data attributes
Data attributeDescription
data-popup-openPresent when the corresponding popup is open.
data-pressedPresent when the trigger is pressed.
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is readonly.
data-popup-sideIndicates which side the corresponding popup is positioned relative to its anchor.
data-requiredPresent when the component is required.
data-validPresent when the component is in a valid state (when wrapped in Field.Root).
data-invalidPresent when the component is in an invalid state (when wrapped in Field.Root).
data-touchedPresent when the component has been touched (when wrapped in Field.Root).
data-dirtyPresent when the component's value has changed (when wrapped in Field.Root).
data-filledPresent when the component has a value (when wrapped in Field.Root).
data-focusedPresent when the trigger is focused (when wrapped in Field.Root).
data-list-emptyPresent when the corresponding items list is empty.
data-placeholderPresent when the combobox doesn't have a value.

ValueCombobox.Value

The current value of the combobox. Doesn’t render its own element.

Value props
PropTypeDefault
placeholderThe placeholder value to display when no value is selected.React.ReactNode—
childrenAccepts a function that returns a ReactNode to format the selected value.React.ReactNode | ((selectedValue: any) => React.ReactNode)—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowDownArrowUpOpens the list and highlights the first or the last itemInput
ArrowDownArrowUpMoves the highlight and wraps at the ends while loopFocus is onList
ArrowDownArrowUpArrowLeftArrowRightWith grid, moves across rows and columnsRow
EnterSelects the highlighted itemItem
EscapeCloses the list without changing the valueInput
EscapeWith the list closed and multiple on, clears the selected valuesInput
TabCloses the list without changing the value and moves focus onInput
BackspaceIn an empty input, removes the last chipInput
ArrowLeftArrowRightMoves between chips and back to the inputChip
DeleteRemoves the focused chipChip
ArrowDownArrowUpOpens the list from the triggerTrigger

Accessibility

What the parts set
PartSetsSo that
Inputrole="combobox", aria-expanded, aria-haspopup, aria-controls to the list while opena screen reader says the input picks from a list and whether it is shown
Inputaria-autocomplete, aria-activedescendant to the highlighted itemthe highlight is read while focus stays in the input
Triggeraria-haspopup ("listbox", or "dialog" when the input is inside the popup), aria-expandedthe button says what it opens
Listrole="listbox" (role="grid" with grid), aria-multiselectable with multiplethe items are announced as options, and whether several can be chosen
Itemrole="option" (role="gridcell" in a Row), aria-selectedeach item is read with its selected state
Rowrole="row"grid rows are announced as rows
Chipsrole="toolbar"the chips are one group the arrow keys move through
Grouparia-labelledby to its GroupLabela group of items is read with its heading
Statusrole="status", aria-live="polite"loading and result counts are announced without moving focus
Emptyrole="status", aria-live="polite""no results" is announced as it appears
Iconaria-hidden="true"the decorative icon is not read

Focus order

  1. Input is the tab stop; arrow keys move a highlight in the list, not focus.
  2. Trigger, Clear and ChipRemove are not tab stops while the input is outside the popup; chips are reached with ArrowLeft from the input.
  3. With the input inside the popup, Trigger is the tab stop and opening moves focus to the input.
  4. Tab closes the list and moves on.