Ark UI Logo
Components
Combobox

Combobox

A single input field that combines the functionality of a select and input.

Loading...

Anatomy

To set up the combobox correctly, you'll need to understand its anatomy and how we name its parts.

Each part includes a data-part attribute to help identify them in the DOM.

Examples

Learn how to use the Combobox component in your project.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const Basic = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems: [
      { label: 'Apple', value: 'apple' },
      { label: 'Banana', value: 'banana' },
      { label: 'Orange', value: 'orange' },
      { label: 'Mango', value: 'mango' },
      { label: 'Pineapple', value: 'pineapple' },
      { label: 'Strawberry', value: 'strawberry' },
    ],
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
      <Combobox.Label className={styles.Label}>Favorite Fruit</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Apple" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Auto Highlight

Automatically highlight the first matching item as the user types by setting inputBehavior="autohighlight".

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const AutoHighlight = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems: [
      { label: 'Engineering', value: 'engineering' },
      { label: 'Marketing', value: 'marketing' },
      { label: 'Sales', value: 'sales' },
      { label: 'Finance', value: 'finance' },
      { label: 'Human Resources', value: 'hr' },
      { label: 'Operations', value: 'operations' },
      { label: 'Product', value: 'product' },
      { label: 'Customer Success', value: 'customer-success' },
      { label: 'Legal', value: 'legal' },
      { label: 'Information Technology', value: 'information-technology' },
      { label: 'Design', value: 'design' },
    ],
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      inputBehavior="autohighlight"
    >
      <Combobox.Label className={styles.Label}>Department</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Engineering" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            <Combobox.Empty className={styles.Item}>No results found</Combobox.Empty>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Inline Autocomplete

Complete the input value with the first matching item by setting inputBehavior="autocomplete". Use with startsWith filter for best results.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const InlineAutocomplete = () => {
  const { startsWith } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems: [
      { label: 'Whale', value: 'whale' },
      { label: 'Dolphin', value: 'dolphin' },
      { label: 'Shark', value: 'shark' },
      { label: 'Octopus', value: 'octopus' },
      { label: 'Jellyfish', value: 'jellyfish' },
      { label: 'Seahorse', value: 'seahorse' },
    ],
    filter: startsWith,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      inputBehavior="autocomplete"
    >
      <Combobox.Label className={styles.Label}>Sea Creature</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Dolphin" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            <Combobox.Empty className={styles.Item}>No results found</Combobox.Empty>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Grouping

To group related combobox items, use the groupBy prop on the collection and collection.group() to iterate the groups.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const Grouping = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems,
    filter: contains,
    groupBy: (item) => item.continent,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
      <Combobox.Label className={styles.Label}>Country</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Canada" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.group().map(([continent, group]) => (
              <Combobox.ItemGroup className={styles.ItemGroup} key={continent}>
                <Combobox.ItemGroupLabel className={styles.ItemGroupLabel}>{continent}</Combobox.ItemGroupLabel>
                {group.map((item) => (
                  <Combobox.Item className={styles.Item} key={item.value} item={item}>
                    <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                    <Combobox.ItemIndicator className={styles.ItemIndicator}>
                      <CheckIcon />
                    </Combobox.ItemIndicator>
                  </Combobox.Item>
                ))}
              </Combobox.ItemGroup>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

const initialItems = [
  { label: 'Canada', value: 'ca', continent: 'North America' },
  { label: 'United States', value: 'us', continent: 'North America' },
  { label: 'Mexico', value: 'mx', continent: 'North America' },
  { label: 'United Kingdom', value: 'uk', continent: 'Europe' },
  { label: 'Germany', value: 'de', continent: 'Europe' },
  { label: 'France', value: 'fr', continent: 'Europe' },
  { label: 'Japan', value: 'jp', continent: 'Asia' },
  { label: 'South Korea', value: 'kr', continent: 'Asia' },
  { label: 'China', value: 'cn', continent: 'Asia' },
]

Field

The Field component helps manage form-related state and accessibility attributes of a combobox. It includes handling ARIA labels, helper text, and error text to ensure proper accessibility.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { Field } from '@ark-ui/react/field'
import { useFilter } from '@ark-ui/react/locale'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'
import field from 'styles/field.module.css'

export const WithField = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems,
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Field.Root className={field.Root}>
      <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
        <Combobox.Label className={styles.Label}>Department</Combobox.Label>
        <Combobox.Control className={styles.Control}>
          <Combobox.Input className={styles.Input} placeholder="e.g. Engineering" />
          <div className={styles.Indicators}>
            <Combobox.ClearTrigger className={styles.ClearTrigger}>
              <XIcon />
            </Combobox.ClearTrigger>
            <Combobox.Trigger className={styles.Trigger}>
              <ChevronsUpDownIcon />
            </Combobox.Trigger>
          </div>
        </Combobox.Control>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Combobox.Root>
      <Field.HelperText className={field.HelperText}>Select your primary department</Field.HelperText>
      <Field.ErrorText className={field.ErrorText}>Department is required</Field.ErrorText>
    </Field.Root>
  )
}

const initialItems = [
  { label: 'Engineering', value: 'engineering' },
  { label: 'Design', value: 'design' },
  { label: 'Marketing', value: 'marketing' },
  { label: 'Sales', value: 'sales' },
  { label: 'Human Resources', value: 'hr' },
  { label: 'Finance', value: 'finance' },
]

Root Provider

Use the useCombobox hook to create the combobox store and pass it to the Combobox.RootProvider component. This allows you to have maximum control over the combobox programmatically.

import { Combobox, useCombobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import button from 'styles/button.module.css'
import styles from 'styles/combobox.module.css'

const initialItems = [
  { label: 'Designer', value: 'designer' },
  { label: 'Developer', value: 'developer' },
  { label: 'Product Manager', value: 'pm' },
  { label: 'Data Scientist', value: 'data-scientist' },
  { label: 'DevOps Engineer', value: 'devops' },
  { label: 'Marketing Lead', value: 'marketing' },
]

export const RootProvider = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems,
    filter: contains,
  })

  const combobox = useCombobox({
    collection,
    onInputValueChange(details) {
      filter(details.inputValue)
    },
  })

  return (
    <div className="stack">
      <button className={button.Root} onClick={() => combobox.focus()}>
        Focus
      </button>

      <Combobox.RootProvider className={styles.Root} value={combobox}>
        <Combobox.Label className={styles.Label}>Job Title</Combobox.Label>
        <Combobox.Control className={styles.Control}>
          <Combobox.Input className={styles.Input} placeholder="e.g. Designer" />
          <div className={styles.Indicators}>
            <Combobox.ClearTrigger className={styles.ClearTrigger}>
              <XIcon />
            </Combobox.ClearTrigger>
            <Combobox.Trigger className={styles.Trigger}>
              <ChevronsUpDownIcon />
            </Combobox.Trigger>
          </div>
        </Combobox.Control>
        <Portal>
          <Combobox.Positioner>
            <Combobox.Content className={styles.Content}>
              {collection.items.map((item) => (
                <Combobox.Item className={styles.Item} key={item.value} item={item}>
                  <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                  <Combobox.ItemIndicator className={styles.ItemIndicator}>
                    <CheckIcon />
                  </Combobox.ItemIndicator>
                </Combobox.Item>
              ))}
            </Combobox.Content>
          </Combobox.Positioner>
        </Portal>
      </Combobox.RootProvider>
    </div>
  )
}

If you're using the Combobox.RootProvider component, you don't need to use the Combobox.Root component.

Use the asChild prop to render the combobox items as links.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const Links = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems,
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      selectionBehavior="preserve"
    >
      <Combobox.Label className={styles.Label}>Developer Resources</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. GitHub" />
        <div className={styles.Indicators}>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item} asChild>
                <a href={item.href}>
                  <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                  <Combobox.ItemIndicator className={styles.ItemIndicator}>
                    <CheckIcon />
                  </Combobox.ItemIndicator>
                </a>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

const initialItems = [
  { label: 'GitHub', href: 'https://github.com', value: 'github' },
  { label: 'Stack Overflow', href: 'https://stackoverflow.com', value: 'stackoverflow' },
  { label: 'MDN Web Docs', href: 'https://developer.mozilla.org', value: 'mdn' },
  { label: 'npm', href: 'https://www.npmjs.com', value: 'npm' },
  { label: 'TypeScript', href: 'https://www.typescriptlang.org', value: 'typescript' },
  { label: 'React', href: 'https://react.dev', value: 'react' },
]

Rehydrate Value

When a combobox has a defaultValue or value but the collection is not loaded yet, you can rehydrate the value to populate the input.

import { Combobox, useCombobox, useComboboxContext, useListCollection } from '@ark-ui/react/combobox'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon } from 'lucide-react'
import { useRef, useState } from 'react'
import { useAsync } from 'react-use'
import styles from 'styles/combobox.module.css'

function ComboboxRehydrateValue() {
  const combobox = useComboboxContext()
  const hydrated = useRef(false)
  if (combobox.value.length && combobox.collection.size && !hydrated.current) {
    combobox.syncSelectedItems()
    hydrated.current = true
  }
  return null
}

export const RehydrateValue = () => {
  const [inputValue, setInputValue] = useState('')

  const { collection, set } = useListCollection<Character>({
    initialItems: [],
    itemToString: (item) => item.name,
    itemToValue: (item) => item.name,
  })

  const combobox = useCombobox({
    collection,
    defaultValue: ['C-3PO'],
    placeholder: 'Example: Dexter',
    inputValue,
    onInputValueChange: (e) => setInputValue(e.inputValue),
  })

  const state = useAsync(async () => {
    const response = await fetch(`https://swapi.py4e.com/api/people/?search=${inputValue}`)
    const data = await response.json()
    set(data.results)
  }, [inputValue, set])

  return (
    <Combobox.RootProvider className={styles.Root} value={combobox}>
      <Combobox.Label className={styles.Label}>Search Star Wars Characters</Combobox.Label>
      <ComboboxRehydrateValue />
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Luke" />
      </Combobox.Control>

      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {state.loading ? (
              <span style={{ padding: '0.5rem' }}>Loading...</span>
            ) : state.error ? (
              <span style={{ padding: '0.5rem' }}>{state.error.message}</span>
            ) : (
              collection.items.map((item) => (
                <Combobox.Item className={styles.Item} key={item.name} item={item}>
                  <Combobox.ItemText className={styles.ItemText}>
                    {item.name} - {item.height}cm / {item.mass}kg
                  </Combobox.ItemText>
                  <Combobox.ItemIndicator className={styles.ItemIndicator}>
                    <CheckIcon />
                  </Combobox.ItemIndicator>
                </Combobox.Item>
              ))
            )}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.RootProvider>
  )
}

interface Character {
  name: string
  height: string
  mass: string
  created: string
  edited: string
  url: string
}

Highlight Matching Text

Highlight the matching search text in combobox items based on the user's input.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { Highlight } from '@ark-ui/react/highlight'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

export const HighlightMatchingText = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter } = useListCollection({
    initialItems: [
      { label: 'John Smith', value: 'john-smith' },
      { label: 'Jane Doe', value: 'jane-doe' },
      { label: 'Bob Johnson', value: 'bob-johnson' },
      { label: 'Alice Williams', value: 'alice-williams' },
      { label: 'Charlie Brown', value: 'charlie-brown' },
      { label: 'Diana Ross', value: 'diana-ross' },
    ],
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
      <Combobox.Label className={styles.Label}>Assignee</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. John Smith" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>
                  <Combobox.Context>
                    {(context) => <Highlight text={item.label} query={context.inputValue} ignoreCase />}
                  </Combobox.Context>
                </Combobox.ItemText>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Dynamic Items

Generate combobox items dynamically based on user input. This is useful for creating suggestions or autocomplete functionality.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import styles from 'styles/combobox.module.css'

const suggestList = ['gmail.com', 'yahoo.com', 'ark-ui.com']

export const Dynamic = () => {
  const { collection, set } = useListCollection<string>({
    initialItems: [],
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    if (details.reason === 'input-change') {
      const items = suggestList.map((item) => `${details.inputValue}@${item}`)
      set(items)
    }
  }

  return (
    <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
      <Combobox.Label className={styles.Label}>Email</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. john" />
        <div className={styles.Indicators}>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Creatable Options

Allow users to create new options when their search doesn't match any existing items. This is useful for tags, categories, or other custom values.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import { useState } from 'react'
import { flushSync } from 'react-dom'
import styles from 'styles/combobox.module.css'

interface Item {
  label: string
  value: string
  __new__?: boolean
}

const NEW_OPTION_VALUE = '[[new]]'
const createNewOption = (value: string): Item => ({ label: value, value: NEW_OPTION_VALUE })
const isNewOptionValue = (value: string) => value === NEW_OPTION_VALUE
const replaceNewOptionValue = (values: string[], value: string) =>
  values.map((v) => (v === NEW_OPTION_VALUE ? value : v))
const getNewOptionData = (inputValue: string): Item => ({ label: inputValue, value: inputValue, __new__: true })

export const Creatable = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const { collection, filter, upsert, update, remove } = useListCollection<Item>({
    initialItems: [
      { label: 'Bug', value: 'bug' },
      { label: 'Feature', value: 'feature' },
      { label: 'Enhancement', value: 'enhancement' },
      { label: 'Documentation', value: 'docs' },
    ],
    filter: contains,
  })

  const isValidNewOption = (inputValue: string) => {
    const exactOptionMatch = collection.filter((item) => item.toLowerCase() === inputValue.toLowerCase()).size > 0
    return !exactOptionMatch && inputValue.trim().length > 0
  }

  const [selectedValue, setSelectedValue] = useState<string[]>([])
  const [inputValue, setInputValue] = useState('')

  const handleInputChange = ({ inputValue, reason }: Combobox.InputValueChangeDetails) => {
    if (reason === 'input-change' || reason === 'item-select') {
      flushSync(() => {
        if (isValidNewOption(inputValue)) {
          upsert(NEW_OPTION_VALUE, createNewOption(inputValue))
        } else if (inputValue.trim().length === 0) {
          remove(NEW_OPTION_VALUE)
        }
      })
      filter(inputValue)
    }
    setInputValue(inputValue)
  }

  const handleOpenChange = ({ reason }: Combobox.OpenChangeDetails) => {
    if (reason === 'trigger-click') {
      filter('')
    }
  }

  const handleValueChange = ({ value }: Combobox.ValueChangeDetails) => {
    setSelectedValue(replaceNewOptionValue(value, inputValue))
    if (value.includes(NEW_OPTION_VALUE)) {
      console.log('New Option Created', inputValue)
      update(NEW_OPTION_VALUE, getNewOptionData(inputValue))
    }
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      onOpenChange={handleOpenChange}
      value={selectedValue}
      onValueChange={handleValueChange}
      allowCustomValue
    >
      <Combobox.Label className={styles.Label}>Label</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Bug" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                {isNewOptionValue(item.value) ? (
                  <Combobox.ItemText className={styles.ItemText}>+ Create "{item.label}"</Combobox.ItemText>
                ) : (
                  <Combobox.ItemText className={styles.ItemText}>
                    {item.label} {item.__new__ ? '(new)' : ''}
                  </Combobox.ItemText>
                )}
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Multiple Selection

Enable multiple selection by setting the multiple prop. Selected items can be displayed as tags above the input.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import { useRef } from 'react'
import styles from 'styles/combobox.module.css'

export const Multiple = () => {
  const { contains } = useFilter({ sensitivity: 'base' })

  const selectedValue = useRef<string[]>([])

  const { collection, filter, remove } = useListCollection({
    initialItems: [
      { label: 'JavaScript', value: 'js' },
      { label: 'TypeScript', value: 'ts' },
      { label: 'Python', value: 'python' },
      { label: 'Go', value: 'go' },
      { label: 'Rust', value: 'rust' },
      { label: 'Java', value: 'java' },
    ],
    filter: contains,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  const handleValueChange = (details: Combobox.ValueChangeDetails) => {
    selectedValue.current = details.value
    remove(...details.value)
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      onValueChange={handleValueChange}
      multiple
    >
      <Combobox.Label className={styles.Label}>Skills</Combobox.Label>
      <Combobox.Context>
        {(combobox) => (
          <div className={styles.Tags}>
            {combobox.selectedItems.length === 0 && <span className={styles.TagPlaceholder}>None selected</span>}
            {combobox.selectedItems.map((item: any) => (
              <span key={item.value} className={styles.Tag}>
                {item.label}
              </span>
            ))}
          </div>
        )}
      </Combobox.Context>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. JavaScript" />
        <div className={styles.Indicators}>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            <Combobox.Empty className={styles.Item}>No skills found</Combobox.Empty>
            {collection.items.map((item) => (
              <Combobox.Item className={styles.Item} key={item.value} item={item}>
                <Combobox.ItemText className={styles.ItemText}>{item.label}</Combobox.ItemText>
                <Combobox.ItemIndicator className={styles.ItemIndicator}>
                  <CheckIcon />
                </Combobox.ItemIndicator>
              </Combobox.Item>
            ))}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

Load options asynchronously based on user input using the useAsyncList hook. This is useful for searching large datasets or fetching data from an API.

import { useAsyncList } from '@ark-ui/react/collection'
import { Combobox, createListCollection } from '@ark-ui/react/combobox'
import { Portal } from '@ark-ui/react/portal'
import { CheckIcon, ChevronsUpDownIcon, LoaderIcon, XIcon } from 'lucide-react'
import { startTransition } from 'react'
import styles from 'styles/combobox.module.css'

interface Movie {
  id: string
  title: string
  year: number
  director: string
  genre: string
}

export const AsyncSearch = () => {
  const list = useAsyncList<Movie>({
    async load({ filterText, signal }) {
      if (!filterText) return { items: [] }

      await new Promise((resolve) => setTimeout(resolve, 300))

      if (signal?.aborted) return { items: [] }

      const items = allMovies.filter(
        (movie) =>
          movie.title.toLowerCase().includes(filterText.toLowerCase()) ||
          movie.director.toLowerCase().includes(filterText.toLowerCase()) ||
          movie.genre.toLowerCase().includes(filterText.toLowerCase()),
      )

      return { items }
    },
  })

  const collection = createListCollection({
    items: list.items,
    itemToString: (item) => item.title,
    itemToValue: (item) => item.id,
  })

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    if (details.reason === 'input-change') {
      startTransition(() => {
        list.setFilterText(details.inputValue)
      })
    }
  }

  return (
    <Combobox.Root className={styles.Root} collection={collection} onInputValueChange={handleInputChange}>
      <Combobox.Label className={styles.Label}>Movie</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Inception" />
        <div className={styles.Indicators}>
          <Combobox.ClearTrigger className={styles.ClearTrigger}>
            <XIcon />
          </Combobox.ClearTrigger>
          <Combobox.Trigger className={styles.Trigger}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            {list.loading ? (
              <div className={styles.Status}>
                <LoaderIcon className={styles.Spinner} />
                <span>Searching...</span>
              </div>
            ) : list.error ? (
              <div className={styles.Status}>{list.error.message}</div>
            ) : list.items.length === 0 ? (
              <div className={styles.Status}>
                {list.filterText ? 'No results found' : 'Start typing to search movies...'}
              </div>
            ) : (
              collection.items.map((movie) => (
                <Combobox.Item className={styles.Item} key={movie.id} item={movie}>
                  <Combobox.ItemText className={styles.ItemText}>
                    <span className={styles.ItemTitle}>{movie.title}</span>
                    <span className={styles.ItemSubtitle}>
                      {movie.year} · {movie.director}
                    </span>
                  </Combobox.ItemText>
                  <Combobox.ItemIndicator className={styles.ItemIndicator}>
                    <CheckIcon />
                  </Combobox.ItemIndicator>
                </Combobox.Item>
              ))
            )}
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

const allMovies: Movie[] = [
  { id: 'inception', title: 'Inception', year: 2010, director: 'Christopher Nolan', genre: 'Sci-Fi' },
  { id: 'the-dark-knight', title: 'The Dark Knight', year: 2008, director: 'Christopher Nolan', genre: 'Action' },
  { id: 'pulp-fiction', title: 'Pulp Fiction', year: 1994, director: 'Quentin Tarantino', genre: 'Crime' },
  { id: 'the-godfather', title: 'The Godfather', year: 1972, director: 'Francis Ford Coppola', genre: 'Crime' },
  { id: 'forrest-gump', title: 'Forrest Gump', year: 1994, director: 'Robert Zemeckis', genre: 'Drama' },
  { id: 'the-matrix', title: 'The Matrix', year: 1999, director: 'The Wachowskis', genre: 'Sci-Fi' },
  { id: 'interstellar', title: 'Interstellar', year: 2014, director: 'Christopher Nolan', genre: 'Sci-Fi' },
  { id: 'parasite', title: 'Parasite', year: 2019, director: 'Bong Joon-ho', genre: 'Thriller' },
  {
    id: 'the-shawshank-redemption',
    title: 'The Shawshank Redemption',
    year: 1994,
    director: 'Frank Darabont',
    genre: 'Drama',
  },
  { id: 'fight-club', title: 'Fight Club', year: 1999, director: 'David Fincher', genre: 'Drama' },
  { id: 'goodfellas', title: 'Goodfellas', year: 1990, director: 'Martin Scorsese', genre: 'Crime' },
  {
    id: 'the-silence-of-the-lambs',
    title: 'The Silence of the Lambs',
    year: 1991,
    director: 'Jonathan Demme',
    genre: 'Thriller',
  },
]

Virtualized

For very large lists, use virtualization with @tanstack/react-virtual to render only the visible items.

import { Combobox, useListCollection } from '@ark-ui/react/combobox'
import { useFilter } from '@ark-ui/react/locale'
import { Portal } from '@ark-ui/react/portal'
import { useVirtualizer } from '@tanstack/react-virtual'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import { useRef } from 'react'
import { flushSync } from 'react-dom'
import styles from 'styles/combobox.module.css'

export const Virtualized = () => {
  const contentRef = useRef<HTMLDivElement | null>(null)

  const { startsWith } = useFilter({ sensitivity: 'base' })

  const { collection, filter, reset } = useListCollection({
    initialItems: countries,
    filter: startsWith,
  })

  const virtualizer = useVirtualizer({
    count: collection.size,
    getScrollElement: () => contentRef.current,
    estimateSize: () => 32,
    overscan: 10,
  })

  const handleScrollToIndex: Combobox.RootProps<Country>['scrollToIndexFn'] = (details) => {
    flushSync(() => {
      virtualizer.scrollToIndex(details.index, {
        align: 'center',
        behavior: 'auto',
      })
    })
  }

  const handleInputChange = (details: Combobox.InputValueChangeDetails) => {
    filter(details.inputValue)
  }

  return (
    <Combobox.Root
      className={styles.Root}
      collection={collection}
      onInputValueChange={handleInputChange}
      scrollToIndexFn={handleScrollToIndex}
    >
      <Combobox.Label className={styles.Label}>Country</Combobox.Label>
      <Combobox.Control className={styles.Control}>
        <Combobox.Input className={styles.Input} placeholder="e.g. Germany" />
        <div className={styles.Indicators}>
          <Combobox.Trigger className={styles.Trigger} onClick={reset}>
            <ChevronsUpDownIcon />
          </Combobox.Trigger>
        </div>
      </Combobox.Control>
      <Portal>
        <Combobox.Positioner>
          <Combobox.Content className={styles.Content}>
            <div
              ref={contentRef}
              className={styles.Scroller}
              style={{ ['--total-size' as string]: `${virtualizer.getTotalSize()}px` }}
            >
              <div style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
                {virtualizer.getVirtualItems().map((virtualItem) => {
                  const item = collection.items[virtualItem.index]
                  return (
                    <Combobox.Item
                      className={styles.Item}
                      key={item.value}
                      item={item}
                      aria-setsize={collection.size}
                      aria-posinset={virtualItem.index + 1}
                      style={{
                        position: 'absolute',
                        top: 0,
                        left: 0,
                        width: '100%',
                        height: `${virtualItem.size}px`,
                        transform: `translateY(${virtualItem.start}px)`,
                      }}
                    >
                      <Combobox.ItemText className={styles.ItemText}>
                        <span aria-hidden style={{ marginRight: 8 }}>
                          {item.emoji}
                        </span>
                        {item.label}
                      </Combobox.ItemText>
                      <Combobox.ItemIndicator className={styles.ItemIndicator}>
                        <CheckIcon />
                      </Combobox.ItemIndicator>
                    </Combobox.Item>
                  )
                })}
              </div>
            </div>
          </Combobox.Content>
        </Combobox.Positioner>
      </Portal>
    </Combobox.Root>
  )
}

interface Country {
  value: string
  label: string
  emoji: string
}

const countries: Country[] = [
  { value: 'AD', label: 'Andorra', emoji: '🇦🇩' },
  { value: 'AE', label: 'United Arab Emirates', emoji: '🇦🇪' },
  { value: 'AF', label: 'Afghanistan', emoji: '🇦🇫' },
  { value: 'AG', label: 'Antigua and Barbuda', emoji: '🇦🇬' },
  { value: 'AI', label: 'Anguilla', emoji: '🇦🇮' },
  { value: 'AL', label: 'Albania', emoji: '🇦🇱' },
  { value: 'AM', label: 'Armenia', emoji: '🇦🇲' },
  { value: 'AO', label: 'Angola', emoji: '🇦🇴' },
  { value: 'AQ', label: 'Antarctica', emoji: '🇦🇶' },
  { value: 'AR', label: 'Argentina', emoji: '🇦🇷' },
  { value: 'AS', label: 'American Samoa', emoji: '🇦🇸' },
  { value: 'AT', label: 'Austria', emoji: '🇦🇹' },
  { value: 'AU', label: 'Australia', emoji: '🇦🇺' },
  { value: 'AW', label: 'Aruba', emoji: '🇦🇼' },
  { value: 'AX', label: 'Åland Islands', emoji: '🇦🇽' },
  { value: 'AZ', label: 'Azerbaijan', emoji: '🇦🇿' },
  { value: 'BA', label: 'Bosnia and Herzegovina', emoji: '🇧🇦' },
  { value: 'BB', label: 'Barbados', emoji: '🇧🇧' },
  { value: 'BD', label: 'Bangladesh', emoji: '🇧🇩' },
  { value: 'BE', label: 'Belgium', emoji: '🇧🇪' },
  { value: 'BF', label: 'Burkina Faso', emoji: '🇧🇫' },
  { value: 'BG', label: 'Bulgaria', emoji: '🇧🇬' },
  { value: 'BH', label: 'Bahrain', emoji: '🇧🇭' },
  { value: 'BI', label: 'Burundi', emoji: '🇧🇮' },
  { value: 'BJ', label: 'Benin', emoji: '🇧🇯' },
  { value: 'BL', label: 'Saint Barthélemy', emoji: '🇧🇱' },
  { value: 'BM', label: 'Bermuda', emoji: '🇧🇲' },
  { value: 'BN', label: 'Brunei', emoji: '🇧🇳' },
  { value: 'BO', label: 'Bolivia', emoji: '🇧🇴' },
  { value: 'BR', label: 'Brazil', emoji: '🇧🇷' },
  { value: 'BS', label: 'Bahamas', emoji: '🇧🇸' },
  { value: 'BT', label: 'Bhutan', emoji: '🇧🇹' },
  { value: 'BW', label: 'Botswana', emoji: '🇧🇼' },
  { value: 'BY', label: 'Belarus', emoji: '🇧🇾' },
  { value: 'BZ', label: 'Belize', emoji: '🇧🇿' },
  { value: 'CA', label: 'Canada', emoji: '🇨🇦' },
  { value: 'CD', label: 'Congo', emoji: '🇨🇩' },
  { value: 'CF', label: 'Central African Republic', emoji: '🇨🇫' },
  { value: 'CH', label: 'Switzerland', emoji: '🇨🇭' },
  { value: 'CI', label: "Côte d'Ivoire", emoji: '🇨🇮' },
  { value: 'CK', label: 'Cook Islands', emoji: '🇨🇰' },
  { value: 'CL', label: 'Chile', emoji: '🇨🇱' },
  { value: 'CM', label: 'Cameroon', emoji: '🇨🇲' },
  { value: 'CN', label: 'China', emoji: '🇨🇳' },
  { value: 'CO', label: 'Colombia', emoji: '🇨🇴' },
  { value: 'CR', label: 'Costa Rica', emoji: '🇨🇷' },
  { value: 'CU', label: 'Cuba', emoji: '🇨🇺' },
  { value: 'CV', label: 'Cabo Verde', emoji: '🇨🇻' },
  { value: 'CY', label: 'Cyprus', emoji: '🇨🇾' },
  { value: 'CZ', label: 'Czech Republic', emoji: '🇨🇿' },
  { value: 'DE', label: 'Germany', emoji: '🇩🇪' },
  { value: 'DJ', label: 'Djibouti', emoji: '🇩🇯' },
  { value: 'DK', label: 'Denmark', emoji: '🇩🇰' },
  { value: 'DM', label: 'Dominica', emoji: '🇩🇲' },
  { value: 'DO', label: 'Dominican Republic', emoji: '🇩🇴' },
  { value: 'DZ', label: 'Algeria', emoji: '🇩🇿' },
  { value: 'EC', label: 'Ecuador', emoji: '🇪🇨' },
  { value: 'EE', label: 'Estonia', emoji: '🇪🇪' },
  { value: 'EG', label: 'Egypt', emoji: '🇪🇬' },
  { value: 'ER', label: 'Eritrea', emoji: '🇪🇷' },
  { value: 'ES', label: 'Spain', emoji: '🇪🇸' },
  { value: 'ET', label: 'Ethiopia', emoji: '🇪🇹' },
  { value: 'FI', label: 'Finland', emoji: '🇫🇮' },
  { value: 'FJ', label: 'Fiji', emoji: '🇫🇯' },
  { value: 'FK', label: 'Falkland Islands', emoji: '🇫🇰' },
  { value: 'FM', label: 'Micronesia', emoji: '🇫🇲' },
  { value: 'FO', label: 'Faroe Islands', emoji: '🇫🇴' },
  { value: 'FR', label: 'France', emoji: '🇫🇷' },
  { value: 'GA', label: 'Gabon', emoji: '🇬🇦' },
  { value: 'GB', label: 'United Kingdom', emoji: '🇬🇧' },
  { value: 'GD', label: 'Grenada', emoji: '🇬🇩' },
  { value: 'GE', label: 'Georgia', emoji: '🇬🇪' },
  { value: 'GH', label: 'Ghana', emoji: '🇬🇭' },
  { value: 'GI', label: 'Gibraltar', emoji: '🇬🇮' },
  { value: 'GL', label: 'Greenland', emoji: '🇬🇱' },
  { value: 'GM', label: 'Gambia', emoji: '🇬🇲' },
  { value: 'GN', label: 'Guinea', emoji: '🇬🇳' },
  { value: 'GQ', label: 'Equatorial Guinea', emoji: '🇬🇶' },
  { value: 'GR', label: 'Greece', emoji: '🇬🇷' },
  { value: 'GT', label: 'Guatemala', emoji: '🇬🇹' },
  { value: 'GU', label: 'Guam', emoji: '🇬🇺' },
  { value: 'GW', label: 'Guinea-Bissau', emoji: '🇬🇼' },
  { value: 'GY', label: 'Guyana', emoji: '🇬🇾' },
  { value: 'HK', label: 'Hong Kong', emoji: '🇭🇰' },
  { value: 'HN', label: 'Honduras', emoji: '🇭🇳' },
  { value: 'HR', label: 'Croatia', emoji: '🇭🇷' },
  { value: 'HT', label: 'Haiti', emoji: '🇭🇹' },
  { value: 'HU', label: 'Hungary', emoji: '🇭🇺' },
  { value: 'ID', label: 'Indonesia', emoji: '🇮🇩' },
  { value: 'IE', label: 'Ireland', emoji: '🇮🇪' },
  { value: 'IL', label: 'Israel', emoji: '🇮🇱' },
  { value: 'IM', label: 'Isle of Man', emoji: '🇮🇲' },
  { value: 'IN', label: 'India', emoji: '🇮🇳' },
  { value: 'IQ', label: 'Iraq', emoji: '🇮🇶' },
  { value: 'IR', label: 'Iran', emoji: '🇮🇷' },
  { value: 'IS', label: 'Iceland', emoji: '🇮🇸' },
  { value: 'IT', label: 'Italy', emoji: '🇮🇹' },
  { value: 'JE', label: 'Jersey', emoji: '🇯🇪' },
  { value: 'JM', label: 'Jamaica', emoji: '🇯🇲' },
  { value: 'JO', label: 'Jordan', emoji: '🇯🇴' },
  { value: 'JP', label: 'Japan', emoji: '🇯🇵' },
  { value: 'KE', label: 'Kenya', emoji: '🇰🇪' },
  { value: 'KG', label: 'Kyrgyzstan', emoji: '🇰🇬' },
  { value: 'KH', label: 'Cambodia', emoji: '🇰🇭' },
  { value: 'KI', label: 'Kiribati', emoji: '🇰🇮' },
  { value: 'KM', label: 'Comoros', emoji: '🇰🇲' },
  { value: 'KN', label: 'Saint Kitts and Nevis', emoji: '🇰🇳' },
  { value: 'KP', label: 'North Korea', emoji: '🇰🇵' },
  { value: 'KR', label: 'South Korea', emoji: '🇰🇷' },
  { value: 'KW', label: 'Kuwait', emoji: '🇰🇼' },
  { value: 'KY', label: 'Cayman Islands', emoji: '🇰🇾' },
  { value: 'KZ', label: 'Kazakhstan', emoji: '🇰🇿' },
  { value: 'LA', label: 'Laos', emoji: '🇱🇦' },
  { value: 'LB', label: 'Lebanon', emoji: '🇱🇧' },
  { value: 'LC', label: 'Saint Lucia', emoji: '🇱🇨' },
  { value: 'LI', label: 'Liechtenstein', emoji: '🇱🇮' },
  { value: 'LK', label: 'Sri Lanka', emoji: '🇱🇰' },
  { value: 'LR', label: 'Liberia', emoji: '🇱🇷' },
  { value: 'LS', label: 'Lesotho', emoji: '🇱🇸' },
  { value: 'LT', label: 'Lithuania', emoji: '🇱🇹' },
  { value: 'LU', label: 'Luxembourg', emoji: '🇱🇺' },
  { value: 'LV', label: 'Latvia', emoji: '🇱🇻' },
  { value: 'LY', label: 'Libya', emoji: '🇱🇾' },
  { value: 'MA', label: 'Morocco', emoji: '🇲🇦' },
  { value: 'MC', label: 'Monaco', emoji: '🇲🇨' },
  { value: 'MD', label: 'Moldova', emoji: '🇲🇩' },
  { value: 'ME', label: 'Montenegro', emoji: '🇲🇪' },
  { value: 'MG', label: 'Madagascar', emoji: '🇲🇬' },
  { value: 'MH', label: 'Marshall Islands', emoji: '🇲🇭' },
  { value: 'MK', label: 'North Macedonia', emoji: '🇲🇰' },
  { value: 'ML', label: 'Mali', emoji: '🇲🇱' },
  { value: 'MM', label: 'Myanmar', emoji: '🇲🇲' },
  { value: 'MN', label: 'Mongolia', emoji: '🇲🇳' },
  { value: 'MO', label: 'Macao', emoji: '🇲🇴' },
  { value: 'MR', label: 'Mauritania', emoji: '🇲🇷' },
  { value: 'MS', label: 'Montserrat', emoji: '🇲🇸' },
  { value: 'MT', label: 'Malta', emoji: '🇲🇹' },
  { value: 'MU', label: 'Mauritius', emoji: '🇲🇺' },
  { value: 'MV', label: 'Maldives', emoji: '🇲🇻' },
  { value: 'MW', label: 'Malawi', emoji: '🇲🇼' },
  { value: 'MX', label: 'Mexico', emoji: '🇲🇽' },
  { value: 'MY', label: 'Malaysia', emoji: '🇲🇾' },
  { value: 'MZ', label: 'Mozambique', emoji: '🇲🇿' },
  { value: 'NA', label: 'Namibia', emoji: '🇳🇦' },
  { value: 'NC', label: 'New Caledonia', emoji: '🇳🇨' },
  { value: 'NE', label: 'Niger', emoji: '🇳🇪' },
  { value: 'NF', label: 'Norfolk Island', emoji: '🇳🇫' },
  { value: 'NG', label: 'Nigeria', emoji: '🇳🇬' },
  { value: 'NI', label: 'Nicaragua', emoji: '🇳🇮' },
  { value: 'NL', label: 'Netherlands', emoji: '🇳🇱' },
  { value: 'NO', label: 'Norway', emoji: '🇳🇴' },
  { value: 'NP', label: 'Nepal', emoji: '🇳🇵' },
  { value: 'NR', label: 'Nauru', emoji: '🇳🇷' },
  { value: 'NU', label: 'Niue', emoji: '🇳🇺' },
  { value: 'NZ', label: 'New Zealand', emoji: '🇳🇿' },
  { value: 'OM', label: 'Oman', emoji: '🇴🇲' },
  { value: 'PA', label: 'Panama', emoji: '🇵🇦' },
  { value: 'PE', label: 'Peru', emoji: '🇵🇪' },
  { value: 'PF', label: 'French Polynesia', emoji: '🇵🇫' },
  { value: 'PG', label: 'Papua New Guinea', emoji: '🇵🇬' },
  { value: 'PH', label: 'Philippines', emoji: '🇵🇭' },
  { value: 'PK', label: 'Pakistan', emoji: '🇵🇰' },
  { value: 'PL', label: 'Poland', emoji: '🇵🇱' },
  { value: 'PR', label: 'Puerto Rico', emoji: '🇵🇷' },
  { value: 'PS', label: 'Palestine', emoji: '🇵🇸' },
  { value: 'PT', label: 'Portugal', emoji: '🇵🇹' },
  { value: 'PW', label: 'Palau', emoji: '🇵🇼' },
  { value: 'PY', label: 'Paraguay', emoji: '🇵🇾' },
  { value: 'QA', label: 'Qatar', emoji: '🇶🇦' },
  { value: 'RO', label: 'Romania', emoji: '🇷🇴' },
  { value: 'RS', label: 'Serbia', emoji: '🇷🇸' },
  { value: 'RU', label: 'Russia', emoji: '🇷🇺' },
  { value: 'RW', label: 'Rwanda', emoji: '🇷🇼' },
  { value: 'SA', label: 'Saudi Arabia', emoji: '🇸🇦' },
  { value: 'SB', label: 'Solomon Islands', emoji: '🇸🇧' },
  { value: 'SC', label: 'Seychelles', emoji: '🇸🇨' },
  { value: 'SD', label: 'Sudan', emoji: '🇸🇩' },
  { value: 'SE', label: 'Sweden', emoji: '🇸🇪' },
  { value: 'SG', label: 'Singapore', emoji: '🇸🇬' },
  { value: 'SI', label: 'Slovenia', emoji: '🇸🇮' },
  { value: 'SK', label: 'Slovakia', emoji: '🇸🇰' },
  { value: 'SL', label: 'Sierra Leone', emoji: '🇸🇱' },
  { value: 'SM', label: 'San Marino', emoji: '🇸🇲' },
  { value: 'SN', label: 'Senegal', emoji: '🇸🇳' },
  { value: 'SO', label: 'Somalia', emoji: '🇸🇴' },
  { value: 'SR', label: 'Suriname', emoji: '🇸🇷' },
  { value: 'SS', label: 'South Sudan', emoji: '🇸🇸' },
  { value: 'ST', label: 'Sao Tome and Principe', emoji: '🇸🇹' },
  { value: 'SV', label: 'El Salvador', emoji: '🇸🇻' },
  { value: 'SY', label: 'Syria', emoji: '🇸🇾' },
  { value: 'SZ', label: 'Eswatini', emoji: '🇸🇿' },
  { value: 'TC', label: 'Turks and Caicos Islands', emoji: '🇹🇨' },
  { value: 'TD', label: 'Chad', emoji: '🇹🇩' },
  { value: 'TG', label: 'Togo', emoji: '🇹🇬' },
  { value: 'TH', label: 'Thailand', emoji: '🇹🇭' },
  { value: 'TJ', label: 'Tajikistan', emoji: '🇹🇯' },
  { value: 'TK', label: 'Tokelau', emoji: '🇹🇰' },
  { value: 'TL', label: 'Timor-Leste', emoji: '🇹🇱' },
  { value: 'TM', label: 'Turkmenistan', emoji: '🇹🇲' },
  { value: 'TN', label: 'Tunisia', emoji: '🇹🇳' },
  { value: 'TO', label: 'Tonga', emoji: '🇹🇴' },
  { value: 'TR', label: 'Türkiye', emoji: '🇹🇷' },
  { value: 'TT', label: 'Trinidad and Tobago', emoji: '🇹🇹' },
  { value: 'TV', label: 'Tuvalu', emoji: '🇹🇻' },
  { value: 'TW', label: 'Taiwan', emoji: '🇹🇼' },
  { value: 'TZ', label: 'Tanzania', emoji: '🇹🇿' },
  { value: 'UA', label: 'Ukraine', emoji: '🇺🇦' },
  { value: 'UG', label: 'Uganda', emoji: '🇺🇬' },
  { value: 'US', label: 'United States', emoji: '🇺🇸' },
  { value: 'UY', label: 'Uruguay', emoji: '🇺🇾' },
  { value: 'UZ', label: 'Uzbekistan', emoji: '🇺🇿' },
  { value: 'VA', label: 'Vatican City', emoji: '🇻🇦' },
  { value: 'VC', label: 'Saint Vincent and the Grenadines', emoji: '🇻🇨' },
  { value: 'VE', label: 'Venezuela', emoji: '🇻🇪' },
  { value: 'VG', label: 'British Virgin Islands', emoji: '🇻🇬' },
  { value: 'VI', label: 'U.S. Virgin Islands', emoji: '🇻🇮' },
  { value: 'VN', label: 'Vietnam', emoji: '🇻🇳' },
  { value: 'VU', label: 'Vanuatu', emoji: '🇻🇺' },
  { value: 'WF', label: 'Wallis and Futuna', emoji: '🇼🇫' },
  { value: 'WS', label: 'Samoa', emoji: '🇼🇸' },
  { value: 'YE', label: 'Yemen', emoji: '🇾🇪' },
  { value: 'YT', label: 'Mayotte', emoji: '🇾🇹' },
  { value: 'ZA', label: 'South Africa', emoji: '🇿🇦' },
  { value: 'ZM', label: 'Zambia', emoji: '🇿🇲' },
  { value: 'ZW', label: 'Zimbabwe', emoji: '🇿🇼' },
]

Guides

Customize the navigate prop on Combobox.Root to integrate with your router. Using Tanstack Router:

import { Combobox } from '@ark-ui/react/combobox'
import { useNavigate } from '@tanstack/react-router'

function Demo() {
  const navigate = useNavigate()
  return (
    <Combobox.Root
      navigate={(e) => {
        navigate({ to: e.node.href })
      }}
    >
      {/* ... */}
    </Combobox.Root>
  )
}

Custom Objects

By default, the combobox collection expects an array of objects with label and value properties. In some cases, you may need to deal with custom objects.

Use the itemToString and itemToValue props to map the custom object to the required interface.

const items = [
  { country: 'United States', code: 'US', flag: '🇺🇸' },
  { country: 'Canada', code: 'CA', flag: '🇨🇦' },
  { country: 'Australia', code: 'AU', flag: '🇦🇺' },
  // ...
]

const { collection } = useListCollection({
  initialItems: items,
  itemToString: (item) => item.country,
  itemToValue: (item) => item.code,
})

Type-Safety

The Combobox.RootComponent type enables you to create closed, strongly typed wrapper components that maintain full type safety for collection items.

This is particularly useful when building reusable combobox components with custom props and consistent styling.

import { Combobox as ArkCombobox, type CollectionItem } from '@ark-ui/react/combobox'
import { useListCollection } from '@ark-ui/react/collection'

interface ComboboxProps<T extends CollectionItem> extends ArkCombobox.RootProps<T> {}

const Combobox: ArkCombobox.RootComponent = (props) => {
  return <ArkCombobox.Root {...props}>{/* ... */}</ArkCombobox.Root>
}

Then, you can use the Combobox component as follows:

const App = () => {
  const { collection } = useListCollection({
    initialItems: [
      { label: 'React', value: 'react' },
      { label: 'Vue', value: 'vue' },
      { label: 'Svelte', value: 'svelte' },
    ],
  })
  return (
    <Combobox
      collection={collection}
      onValueChange={(e) => {
        // this will be strongly typed Array<{ label: string, value: string }>
        console.log(e.items)
      }}
    >
      {/* ... */}
    </Combobox>
  )
}

Limit Large Datasets

The recommended way of managing large lists is to use the limit property on the useListCollection hook. This will limit the number of rendered items in the DOM to improve performance.

const { collection } = useListCollection({
  initialItems: items,
  limit: 10,
})

Available height and width

The following css variables are exposed to the Combobox.Positioner which you can use to style the Combobox.Content

/* width of the combobox control */
--reference-width: <pixel-value>;
/* width of the available viewport */
--available-width: <pixel-value>;
/* height of the available viewport */
--available-height: <pixel-value>;

For example, if you want to make sure the maximum height doesn't exceed the available height, you can use the following:

[data-scope='combobox'][data-part='content'] {
  max-height: calc(var(--available-height) - 100px);
}

API Reference

Props

Root

PropDefaultType
collection
ListCollection<T>

The collection of items

allowCustomValue
boolean

Whether to allow typing custom values in the input

alwaysSubmitOnEnterfalse
boolean

Whether to always submit on Enter key press, even if popup is open. Useful for single-field autocomplete forms where Enter should submit the form.

asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
autoFocus
boolean

Whether to autofocus the input on mount

closeOnSelect
boolean

Whether to close the combobox when an item is selected.

compositetrue
boolean

Whether the combobox is a composed with other composite widgets like tabs

defaultHighlightedValue
string

The initial highlighted value of the combobox when rendered. Use when you don't need to control the highlighted value of the combobox.

defaultInputValue''
string

The initial value of the combobox's input when rendered. Use when you don't need to control the value of the combobox's input.

defaultOpen
boolean

The initial open state of the combobox when rendered. Use when you don't need to control the open state of the combobox.

defaultValue[]
string[]

The initial value of the combobox's selected items when rendered. Use when you don't need to control the value of the combobox's selected items.

disabled
boolean

Whether the combobox is disabled

disableLayer
boolean

Whether to disable registering this a dismissable layer

form
string

The associate form of the combobox.

highlightedValue
string

The controlled highlighted value of the combobox

id
string

The unique identifier of the machine.

ids
Partial<{ root: string label: string control: string input: string content: string trigger: string clearTrigger: string item: (id: string, index?: number | undefined) => string positioner: string itemGroup: (id: string | number) => string itemGroupLabel: (id: string | number) => string }>

The ids of the elements in the combobox. Useful for composition.

immediate
boolean

Whether to synchronize the present change immediately or defer it to the next frame

inputBehavior'none'
'none' | 'autohighlight' | 'autocomplete'

Defines the auto-completion behavior of the combobox. - `autohighlight`: The first focused item is highlighted as the user types - `autocomplete`: Navigating the listbox with the arrow keys selects the item and the input is updated

inputValue
string

The controlled value of the combobox's input

invalid
boolean

Whether the combobox is invalid

lazyMountfalse
boolean

Whether to enable lazy mounting

loopFocustrue
boolean

Whether to loop the keyboard navigation through the items

multiple
boolean

Whether to allow multiple selection. **Good to know:** When `multiple` is `true`, the `selectionBehavior` is automatically set to `clear`. It is recommended to render the selected items in a separate container.

name
string

The `name` attribute of the combobox's input. Useful for form submission

navigate
(details: NavigateDetails) => void

Function to navigate to the selected item

onExitComplete
VoidFunction

Function called when the animation ends in the closed state

onFocusOutside
(event: FocusOutsideEvent) => void

Function called when the focus is moved outside the component

onHighlightChange
(details: HighlightChangeDetails<T>) => void

Function called when an item is highlighted using the pointer or keyboard navigation.

onInputValueChange
(details: InputValueChangeDetails) => void

Function called when the input's value changes

onInteractOutside
(event: InteractOutsideEvent) => void

Function called when an interaction happens outside the component

onOpenChange
(details: OpenChangeDetails) => void

Function called when the popup is opened

onPointerDownOutside
(event: PointerDownOutsideEvent) => void

Function called when the pointer is pressed down outside the component

onSelect
(details: SelectionDetails) => void

Function called when an item is selected

onValueChange
(details: ValueChangeDetails<T>) => void

Function called when a new item is selected

open
boolean

The controlled open state of the combobox

openOnChangetrue
boolean | ((details: InputValueChangeDetails) => boolean)

Whether to show the combobox when the input value changes

openOnClickfalse
boolean

Whether to open the combobox popup on initial click on the input

openOnKeyPresstrue
boolean

Whether to open the combobox on arrow key press

placeholder
string

The placeholder text of the combobox's input

positioning{ placement: 'bottom-start' }
PositioningOptions

The positioning options to dynamically position the menu

present
boolean

Whether the node is present (controlled by the user)

readOnly
boolean

Whether the combobox is readonly. This puts the combobox in a "non-editable" mode but the user can still interact with it

required
boolean

Whether the combobox is required

scrollToIndexFn
(details: ScrollToIndexDetails) => void

Function to scroll to a specific index

selectionBehavior'replace'
'clear' | 'replace' | 'preserve'

The behavior of the combobox input when an item is selected - `replace`: The selected item string is set as the input value - `clear`: The input value is cleared - `preserve`: The input value is preserved

skipAnimationOnMountfalse
boolean

Whether to allow the initial presence animation.

translations
IntlTranslations

Specifies the localized strings that identifies the accessibility elements and their states

unmountOnExitfalse
boolean

Whether to unmount on exit.

value
string[]

The controlled value of the combobox's selected items

Data AttributeValue
[data-scope]combobox
[data-part]root
[data-invalid]Present when invalid
[data-readonly]Present when read-only

ClearTrigger

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]clear-trigger
[data-invalid]Present when invalid

Content

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
CSS VariableDescription
--layer-indexThe index of the dismissable in the layer stack
--nested-layer-countThe number of nested comboboxs
Data AttributeValue
[data-scope]combobox
[data-part]content
[data-state]"open" | "closed"
[data-nested]listbox
[data-has-nested]listbox
[data-placement]The placement of the content
[data-empty]Present when the content is empty

Control

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]control
[data-state]"open" | "closed"
[data-focus]Present when focused
[data-disabled]Present when disabled
[data-invalid]Present when invalid

Empty

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.

Input

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]input
[data-invalid]Present when invalid
[data-autofocus]
[data-state]"open" | "closed"

ItemGroupLabel

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.

ItemGroup

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]item-group
[data-empty]Present when the content is empty

ItemIndicator

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]item-indicator
[data-state]"checked" | "unchecked"

Item

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
item
any

The item to render

persistFocus
boolean

Whether hovering outside should clear the highlighted state

Data AttributeValue
[data-scope]combobox
[data-part]item
[data-highlighted]Present when highlighted
[data-state]"checked" | "unchecked"
[data-disabled]Present when disabled
[data-value]The value of the item

ItemText

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]item-text
[data-state]"checked" | "unchecked"
[data-disabled]Present when disabled
[data-highlighted]Present when highlighted

Label

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]label
[data-readonly]Present when read-only
[data-disabled]Present when disabled
[data-invalid]Present when invalid
[data-required]Present when required
[data-focus]Present when focused

List

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
Data AttributeValue
[data-scope]combobox
[data-part]list
[data-empty]Present when the content is empty

Positioner

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
CSS VariableDescription
--reference-widthThe width of the reference element
--reference-heightThe height of the root
--available-widthThe available width in viewport
--available-heightThe available height in viewport
--xThe x position for transform
--yThe y position for transform
--z-indexThe z-index value
--transform-originThe transform origin for animations

RootProvider

PropDefaultType
value
UseComboboxReturn<T>

asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
immediate
boolean

Whether to synchronize the present change immediately or defer it to the next frame

lazyMountfalse
boolean

Whether to enable lazy mounting

onExitComplete
VoidFunction

Function called when the animation ends in the closed state

present
boolean

Whether the node is present (controlled by the user)

skipAnimationOnMountfalse
boolean

Whether to allow the initial presence animation.

unmountOnExitfalse
boolean

Whether to unmount on exit.

Trigger

PropDefaultType
asChild
boolean

Use the provided child element as the default rendered element, combining their props and behavior.

For more details, read our Composition guide.
focusable
boolean

Whether the trigger is focusable

Data AttributeValue
[data-scope]combobox
[data-part]trigger
[data-state]"open" | "closed"
[data-invalid]Present when invalid
[data-focusable]
[data-readonly]Present when read-only
[data-disabled]Present when disabled

Context

These are the properties available when using Combobox.Context, useComboboxContext hook or useCombobox hook.

API

PropertyType
focused
boolean

Whether the combobox is focused

open
boolean

Whether the combobox is open

inputValue
string

The value of the combobox input

highlightedValue
string

The value of the highlighted item

highlightedItem
V

The highlighted item

setHighlightValue
(value: string) => void

The value of the combobox input

clearHighlightValue
VoidFunction

Function to clear the highlighted value

syncSelectedItems
VoidFunction

Function to sync the selected items with the value. Useful when `value` is updated from async sources.

selectedItems
V[]

The selected items

hasSelectedItems
boolean

Whether there's a selected item

value
string[]

The selected item keys

valueAsString
string

The string representation of the selected items

selectValue
(value: string) => void

Function to select a value

setValue
(value: string[]) => void

Function to set the value of the combobox

clearValue
(value?: string) => void

Function to clear the value of the combobox

focus
VoidFunction

Function to focus on the combobox input

setInputValue
(value: string, reason?: InputValueChangeReason) => void

Function to set the input value of the combobox

getItemState
(props: ItemProps) => ItemState

Returns the state of a combobox item

setOpen
(open: boolean, reason?: OpenChangeReason) => void

Function to open or close the combobox

collection
ListCollection<V>

Function to toggle the combobox

reposition
(options?: Partial<PositioningOptions>) => void

Function to set the positioning options

multiple
boolean

Whether the combobox allows multiple selections

disabled
boolean

Whether the combobox is disabled

Accessibility

Complies with the Combobox WAI-ARIA design pattern.

Keyboard Support

KeyDescription
ArrowDown
When the combobox is closed, opens the listbox and highlights to the first option. When the combobox is open, moves focus to the next option.
ArrowUp
When the combobox is closed, opens the listbox and highlights to the last option. When the combobox is open, moves focus to the previous option.
Home
When the combobox is open, moves focus to the first option.
End
When the combobox is open, moves focus to the last option.
Escape
Closes the listbox.
Enter
Selects the highlighted option and closes the combobox.
Esc
Closes the combobox