EmpireUI
Get Pro
← Blog8 min read#combobox#autocomplete#react

Combobox in React: Accessible Autocomplete With Headless UI

Build an accessible combobox autocomplete in React with Headless UI. ARIA patterns, keyboard nav, filtering, and styling — all covered with working code.

React combobox autocomplete component with dropdown suggestions on dark background

Why Combobox Is Harder Than It Looks

Most developers reach for a combobox and think: it's just an input with a dropdown. Then three hours later they're reading through WCAG 2.1 specs trying to figure out why VoiceOver isn't announcing the suggestions correctly. The combobox pattern is deceptively complex.

In practice, there are two distinct ARIA patterns you have to pick between: the list autocomplete (aria-autocomplete='list') where the input value stays what the user typed, and the inline autocomplete pattern where the input value updates as you arrow through options. Most designers spec the former; most developers accidentally build something in between and ship broken UX.

Keyboard navigation alone covers Enter, Escape, ArrowUp, ArrowDown, Tab, Home, and End — and they all have specific behaviors defined in the ARIA Authoring Practices Guide. Get any of them wrong and you've got a component that fails automated accessibility audits. Worth noting: the WAI-ARIA spec was updated in 2023 to formalize combobox patterns, so older tutorials are likely steering you wrong.

That said, you don't have to implement all of this from scratch. Headless UI v2.x ships a Combobox component that handles the ARIA wiring, focus management, and keyboard interactions. You bring the UI. Let's build one.

Installing Headless UI and Setting Up the Project

Headless UI v2.0 dropped in early 2024 and requires React 18+. If you're on an older setup, this is a good reason to upgrade — React 18's concurrent features make the focus management noticeably smoother.

npm install @headlessui/react
# or
pnpm add @headlessui/react

Headless UI doesn't ship any styles at all. That's the point. You wire up Tailwind, CSS Modules, or whatever you're using — you stay in control of every pixel. If you want something styled out of the box, you'd be looking at Radix UI or a full component library, but then you're fighting their opinions rather than writing your own.

Quick aside: Headless UI's Combobox is built on top of the same hooks that power their Listbox component, so if you've used that, the mental model transfers directly. The key addition is the ComboboxInput which manages the controlled text state and the filtering logic.

import {
  Combobox,
  ComboboxInput,
  ComboboxButton,
  ComboboxOptions,
  ComboboxOption,
} from '@headlessui/react'

Building the Combobox Component

Here's a working, accessible combobox with filtering. The pattern is: you hold state for the selected value and the query, filter your data array on the query, and pass everything down to the Headless UI primitives.

import { useState, useMemo } from 'react'
import {
  Combobox,
  ComboboxInput,
  ComboboxButton,
  ComboboxOptions,
  ComboboxOption,
} from '@headlessui/react'
import { ChevronDownIcon, CheckIcon } from '@heroicons/react/20/solid'

const frameworks = [
  { id: 1, name: 'Next.js' },
  { id: 2, name: 'Remix' },
  { id: 3, name: 'Astro' },
  { id: 4, name: 'SvelteKit' },
  { id: 5, name: 'Nuxt' },
]

export function FrameworkCombobox() {
  const [selected, setSelected] = useState(frameworks[0])
  const [query, setQuery] = useState('')

  const filtered = useMemo(() =>
    query === ''
      ? frameworks
      : frameworks.filter((f) =>
          f.name.toLowerCase().includes(query.toLowerCase())
        ),
    [query]
  )

  return (
    <Combobox value={selected} onChange={setSelected} onClose={() => setQuery('')}>
      <div className="relative">
        <ComboboxInput
          className="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-2.5 text-sm text-white
                     placeholder:text-white/50 focus:outline-none focus:ring-2 focus:ring-violet-500"
          displayValue={(f) => f?.name}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search frameworks…"
        />
        <ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-3">
          <ChevronDownIcon className="h-4 w-4 text-white/60" />
        </ComboboxButton>
      </div>

      <ComboboxOptions
        anchor="bottom"
        className="mt-1 w-[var(--input-width)] rounded-xl border border-white/20
                   bg-gray-900/95 p-1 shadow-xl backdrop-blur-md empty:hidden"
      >
        {filtered.map((f) => (
          <ComboboxOption
            key={f.id}
            value={f}
            className="group flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white
                       data-[focus]:bg-violet-600 cursor-pointer"
          >
            <CheckIcon className="invisible h-4 w-4 group-data-[selected]:visible" />
            {f.name}
          </ComboboxOption>
        ))}

        {filtered.length === 0 && (
          <p className="px-3 py-2 text-sm text-white/50">No results for "{query}"</p>
        )}
      </ComboboxOptions>
    </Combobox>
  )
}

The onClose={() => setQuery('')} is easy to miss but it matters — it clears the filter when the dropdown closes, so the next time someone opens it they see the full list, not whatever they searched for last time.

One more thing — anchor="bottom" on ComboboxOptions is new in Headless UI v2. It handles the positioning automatically and flips to top when there's not enough viewport space below. In v1.x you had to reach for Floating UI yourself for this.

Honestly, the data-[focus] and data-[selected] CSS selectors are one of the cleaner APIs in Headless UI. No className functions, no render props for state — just CSS data attributes you can target directly in Tailwind. It makes the option styling readable at a glance.

Async Filtering and Server-Side Search

Client-side filtering works great for lists under ~500 items. Beyond that, or when your data lives on a server, you want to debounce the query and hit an API instead.

import { useState, useEffect, useCallback } from 'react'
import { useDebouncedCallback } from 'use-debounce'

export function AsyncCombobox() {
  const [selected, setSelected] = useState(null)
  const [query, setQuery] = useState('')
  const [options, setOptions] = useState([])
  const [loading, setLoading] = useState(false)

  const search = useDebouncedCallback(async (q) => {
    if (!q) { setOptions([]); return }
    setLoading(true)
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
      const data = await res.json()
      setOptions(data.items)
    } finally {
      setLoading(false)
    }
  }, 300) // 300ms debounce

  return (
    <Combobox value={selected} onChange={setSelected}>
      <ComboboxInput
        onChange={(e) => {
          setQuery(e.target.value)
          search(e.target.value)
        }}
        displayValue={(item) => item?.label}
        className="/* your styles */"
      />
      <ComboboxOptions>
        {loading && <div className="px-3 py-2 text-sm text-white/50">Loading…</div>}
        {!loading && options.map((item) => (
          <ComboboxOption key={item.id} value={item}>
            {item.label}
          </ComboboxOption>
        ))}
      </ComboboxOptions>
    </Combobox>
  )
}

The 300ms debounce is the standard sweet spot — short enough to feel responsive, long enough that you're not hammering your API on every keystroke. Some people go as low as 150ms for local APIs where latency is near zero.

Worth noting: if you're in a Next.js App Router project, you can also use a Server Action here instead of a fetch call. Pass the action to a useTransition and you get loading states for free without the manual setLoading dance.

For the empty state, consider being specific: "No results for 'framewrk'" is more useful than a generic "Nothing found" message. Users who see their typo reflected back at them immediately understand what happened.

Styling Your Combobox With Glassmorphism

The dropdown panel is a natural candidate for glassmorphism — backdrop-blur + bg-white/10 gives it that frosted glass look that reads clearly over complex backgrounds. If you want to go deeper into that aesthetic, the glassmorphism generator is worth bookmarking for generating the exact CSS values.

/* Glassmorphic dropdown panel */
.combobox-panel {
  background: rgba(15, 15, 25, 0.85);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 12px;
  box-shadow:
    0 4px 24px rgba(0, 0, 0, 0.4),
    inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

The inset 0 1px 0 top-edge highlight is what separates polished glassmorphism from the flat rgba look. It simulates how light catches the top edge of a glass surface. Subtle, but it adds 20px worth of depth.

For dark-themed apps, set the input's border to rgba(255,255,255,0.15) at rest and rgba(139,92,246,0.6) on focus. That violet focus ring against a dark background reads at 4.5:1 contrast, which clears WCAG AA for UI components. You can see similar patterns in action across the glassmorphism components in the Empire UI library.

If glassmorphism isn't your brand, the same combobox structure works with any visual system. Slap on the neumorphism shadows or go full neobrutalism with hard borders and bold fills — Headless UI doesn't care what CSS you use.

Accessibility Checklist Before You Ship

You've got the component working. Before it goes to production, run through this. Screen reader support is the part that tends to get skipped, and it's the part that actually matters for WCAG compliance.

Headless UI handles role="combobox", aria-expanded, aria-activedescendant, and aria-autocomplete automatically. What it doesn't do is guarantee your custom markup doesn't break those attributes. If you wrap ComboboxOption in extra divs, test with VoiceOver on macOS or NVDA on Windows — the announcement chain can break in non-obvious ways.

Here's the quick checklist you'd want to run through: Does pressing Escape close the dropdown and return focus to the input? Does pressing Enter on an option select it and close the dropdown? Does Tab move focus out of the combobox entirely (not cycle through options)? Does the selected option announce correctly in screen readers? Are your color contrast ratios passing — minimum 4.5:1 for text, 3:1 for UI components?

Look, accessibility isn't optional if you're building B2B or any product that touches government, healthcare, or education verticals. And beyond compliance, a keyboard-navigable combobox is just better UX for power users who hate lifting their hands off the keyboard.

One more thing — if you're writing tests, @testing-library/user-event v14+ handles combobox interactions correctly. Use userEvent.type(input, 'Next') followed by userEvent.keyboard('{ArrowDown}{Enter}') to test selection. The older fireEvent approach misses the synthetic keyboard events that ARIA relies on.

Multi-Select Combobox

Single-select covers most cases. But sometimes you need users to tag multiple items — tech stacks, team members, filter chips. Headless UI's Combobox supports multiple mode with one prop change.

// Multi-select: value is now an array
const [selected, setSelected] = useState<Framework[]>([])

<Combobox value={selected} onChange={setSelected} multiple>
  <div className="flex flex-wrap gap-1.5 p-2 border border-white/20 rounded-lg">
    {/* Render selected chips */}
    {selected.map((f) => (
      <span
        key={f.id}
        className="flex items-center gap-1 rounded-full bg-violet-600/30 px-2.5 py-0.5
                   text-xs font-medium text-violet-200"
      >
        {f.name}
        <button
          onClick={(e) => {
            e.stopPropagation()
            setSelected(selected.filter((s) => s.id !== f.id))
          }}
          className="hover:text-white"
        >
          ×
        </button>
      </span>
    ))}
    <ComboboxInput
      className="flex-1 min-w-[120px] bg-transparent text-sm text-white
                 outline-none placeholder:text-white/40"
      onChange={(e) => setQuery(e.target.value)}
      placeholder={selected.length === 0 ? 'Select frameworks…' : ''}
    />
  </div>
  <ComboboxOptions>…</ComboboxOptions>
</Combobox>

The e.stopPropagation() on the chip remove button is critical. Without it, clicking the X will bubble up and trigger the Combobox to open the dropdown, which is not what anyone wants.

In multi-select mode, the displayValue prop on ComboboxInput becomes less relevant since you're rendering the selections as chips outside the input. Set it to () => '' or just omit it and control the input value manually through the onChange handler.

FAQ

Does Headless UI's Combobox work with React Hook Form?

Yes. Wrap it in a Controller and pass field.onChange to the Combobox's onChange prop. Set field.value as the value prop. Works cleanly — no hacks needed.

What's the difference between Combobox and Select in Headless UI?

Combobox has a text input for filtering; Select (Listbox) doesn't. Use Combobox when users need to search through options, use Listbox when the list is short and browsable.

How do I handle the case where the user types something that's not in the list?

Check if filtered.length === 0 after filtering and render a 'Create "query"' option. On select, push the new item to your data source and set it as selected — the pattern's called 'creatable combobox'.

Is Headless UI Combobox WCAG 2.1 AA compliant out of the box?

The ARIA roles and keyboard interactions are correct, but color contrast is your responsibility since Headless UI ships zero styles. Test with axe DevTools and real screen readers before shipping.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Modal / Dialog: Headless UI, Radix UI and the Vanilla WayDropdown Menu in React: Accessible, Animated, Keyboard-ReadyReact Aria Components: Headless UI Done Right by AdobeHeadless UI Libraries in 2026: Radix, Headless UI, Ark Compared