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.
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/reactHeadless 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
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.
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.
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'.
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.