Build an Accessible React Combobox with React Aria (2026)
July 2, 2026
A combobox is one of the hardest widgets to build by hand: it needs role="combobox", aria-expanded, aria-controls, aria-activedescendant, and full keyboard support, wired together correctly. This guide builds one with react-aria-components 1.19.0, styles it with CSS, and proves it's accessible with a real jest-axe test suite.
TL;DR
You'll build a filterable, keyboard-navigable combobox using react-aria-components 1.19.0 (React 19.2, TypeScript 6, Vite 8), following the WAI-ARIA Combobox pattern without writing a single aria-* attribute by hand. Then you'll wire it into a form with required-field validation, and verify the whole thing with jest-axe (zero violations, checked both closed and open) plus @testing-library/user-event keyboard interaction tests. Every code block in this post is copy-pasted from a project that actually typechecks, builds, and passes its test suite. Budget about 30 minutes.
What you'll learn
- Why hand-rolling ARIA combobox semantics is a common source of accessibility bugs, and what the spec actually requires
- How to install and structure a
react-aria-componentsComboBoxusing its real 1.19.0 API (not the newer preview API shown on the docs site) - How to style a headless combobox with plain CSS using its
data-*state attributes - How filtering, the empty state, and the
menuTriggeroption actually behave — including two gotchas that will surprise you if you've only read the docs - How to wire the combobox into a native
<form>withisRequiredandFieldError - How to write an automated accessibility test with
jest-axe - How to test full keyboard interaction (type-to-filter, arrow-to-focus, Enter-to-select, Escape-to-close) with
@testing-library/user-event
Prerequisites
- Node.js 20.19+ or 22.12+ (Vite 8's
enginesrequirement — Vitest 4 accepts^20.0.0 || ^22.0.0 || >=24.0.0, so either LTS line works) reactandreact-dom19.2.7 (react-aria-components' peer range also covers React 16.8–19, so this isn't React-19-only)- Familiarity with React function components and hooks
- No prior React Aria experience needed
Install the runtime dependency:
npm install react-aria-components@1.19.0
And the dev/test toolchain used in this guide:
npm install -D vite@8.1.2 @vitejs/plugin-react@6.0.3 vitest@4.1.9 jsdom@29.1.1 \
@testing-library/react@16.3.2 @testing-library/user-event@14.6.1 \
@testing-library/jest-dom@6.9.1 jest-axe@10.0.0 @types/jest-axe@3.5.9 \
typescript@6.0.3
Step 1: Why not just write the divs yourself?
The WAI-ARIA Authoring Practices Guide (APG) Combobox pattern specifies a role="combobox" input that owns aria-controls, aria-expanded, and aria-autocomplete (list for this guide's filter-as-you-type behavior), plus a popup listbox whose focused option is tracked with aria-activedescendant on the input itself — DOM focus never actually leaves the text field, and each option in the popup needs aria-selected set correctly as that virtual focus moves.1 Get any one of those wrong — a stale aria-controls id, an aria-expanded that doesn't flip, activedescendant IDs that don't match rendered option IDs — and a screen reader user gets silence or wrong information, while the widget still looks perfectly normal to a sighted user testing it with a mouse.
None of these mistakes throw a console error or fail a visual review. Miss aria-activedescendant and a screen reader announces nothing as the user arrows through suggestions. Forget to update aria-expanded, and assistive tech reports the popup as closed while it's visibly open. They only show up when someone actually navigates with a keyboard and a screen reader — which is exactly why this pattern shows up so often in accessibility audits.
react-aria-components implements this pattern for you at the hook level (useComboBox) and exposes it as headless, unstyled components, so you write markup and CSS, and the library manages the ARIA wiring, keyboard interaction, and focus management.12
Step 2: Build the base ComboBox
Create Combobox.tsx. This wraps react-aria-components' ComboBox in a small, reusable, generic component:
// src/Combobox.tsx
import {
Button,
ComboBox,
Input,
Label,
ListBox,
Popover,
Text,
FieldError,
type ComboBoxProps as AriaComboBoxProps,
type Key,
type ValidationResult
} from 'react-aria-components';
export interface ComboboxOption {
id: string;
name: string;
}
interface ComboboxProps<T extends ComboboxOption>
extends Omit<AriaComboBoxProps<T>, 'children'> {
label: string;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
items: Iterable<T>;
children: (item: T) => React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
// All ARIA roles, states (aria-expanded, aria-activedescendant,
// aria-controls), keyboard handling, and focus management come from
// react-aria-components. This wrapper only supplies markup + styling hooks.
// React 19 lets function components accept `ref` as a plain prop, so no
// forwardRef wrapper is needed here (see Troubleshooting).
export function Combobox<T extends ComboboxOption>({
label,
description,
errorMessage,
items,
children,
ref,
...props
}: ComboboxProps<T>) {
return (
<ComboBox {...props} ref={ref} defaultItems={items} className="combobox">
<Label className="combobox-label">{label}</Label>
<div className="combobox-field">
<Input className="combobox-input" />
<Button className="combobox-button" aria-label="Show suggestions">
▼
</Button>
</div>
{description && (
<Text slot="description" className="combobox-description">
{description}
</Text>
)}
<FieldError className="combobox-error">{errorMessage}</FieldError>
<Popover className="combobox-popover">
<ListBox
className="combobox-listbox"
renderEmptyState={() => (
<span className="combobox-empty">No matches found</span>
)}
>
{children}
</ListBox>
</Popover>
</ComboBox>
);
}
export { ListBoxItem } from 'react-aria-components';
export type { Key };
Two things worth calling out here, because they trip people up who read the marketing docs first instead of the installed package: ComboBox in 1.19.0 does not accept label or placeholder props directly — those are handled by the <Label> child and <Input placeholder="…"> — and there is no ComboBoxItem export. Items are plain ListBoxItems, same as a standalone ListBox. You can confirm this yourself by reading node_modules/react-aria-components/dist/types/exports/ComboBox.d.ts after installing — the docs site currently previews a newer, unreleased API shape (ComboBoxItem, a <Group> wrapper, label/placeholder as direct props) that doesn't match what npm ships as 1.19.0.2
Step 3: Use it with a real list
// src/App.tsx
import { useState } from 'react';
import { Combobox, ListBoxItem, type ComboboxOption, type Key } from './Combobox';
interface Framework extends ComboboxOption {
name: string;
}
const frameworks: Framework[] = [
{ id: 'react', name: 'React' },
{ id: 'vue', name: 'Vue' },
{ id: 'svelte', name: 'Svelte' },
{ id: 'solid', name: 'Solid' },
{ id: 'angular', name: 'Angular' },
{ id: 'qwik', name: 'Qwik' },
{ id: 'astro', name: 'Astro' }
];
export function App() {
// Use react-aria-components' own `Key` type, not React's built-in one --
// they are structurally different and mixing them breaks the typecheck
// on `selectedKey` (see Troubleshooting).
const [selected, setSelected] = useState<Key | null>(null);
return (
<form aria-label="Framework picker">
<Combobox
label="Favorite framework"
description="Type to filter, then pick one with the keyboard or mouse."
items={frameworks}
name="framework"
selectedKey={selected}
onSelectionChange={setSelected}
isRequired
errorMessage="Please choose a framework."
allowsEmptyCollection
>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</Combobox>
<p data-testid="selection">Selected: {selected ?? 'none'}</p>
</form>
);
}
defaultItems (set inside Combobox.tsx) makes the item list uncontrolled — react-aria-components filters it internally as the user types, using a case-insensitive "contains" match by default. If you need custom filtering logic (fuzzy matching, filtering by a field other than the displayed text), pass a defaultFilter function to ComboBox.
Step 4: Style it — it's headless, not unstyled-forever
react-aria-components renders plain DOM elements with data-* attributes reflecting state, so you style it with ordinary CSS and no runtime style props:
/* Combobox.css */
.combobox-field {
display: flex;
align-items: center;
border: 1px solid #6b7280;
border-radius: 6px;
}
.combobox-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
outline: none;
}
/* [data-focused] is set by the library while the input has focus */
.combobox-input[data-focused] {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
.combobox-popover {
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.combobox-listbox {
max-height: 240px;
overflow-y: auto;
padding: 0.25rem;
}
/* [data-focused] here means "virtually focused via aria-activedescendant" */
[data-focused].react-aria-ListBoxItem {
background: #eff6ff;
}
[data-selected].react-aria-ListBoxItem {
font-weight: 600;
}
.combobox-error {
color: #dc2626;
font-size: 0.875rem;
}
Every interactive element exposes the state selectors you'd expect: [data-open] and [data-invalid] on the ComboBox root, [data-disabled] and [data-hovered] on individual items, and so on — check a rendered DOM tree in your browser devtools to see the full attribute list for any given component.
Step 5: Two behaviors that will surprise you
These aren't bugs — they're exactly what the spec asks for — but they don't match what most people expect the first time:
Typing does not open the popup on its own for an empty result set. By default, ComboBox hides the popup entirely when the filtered collection is empty, so your renderEmptyState message never renders. Pass allowsEmptyCollection (as in Step 3) if you want a visible "no matches" state instead of the popover silently staying closed.
Clicking or focusing the input alone does not open the popup. The default menuTrigger is 'input', meaning the popover opens only when the user edits the text — not merely on click or focus. If you want the popup to open on focus (common for "browse all options" comboboxes), set menuTrigger="focus"; if you want it to open only via the trigger button or arrow keys, use menuTrigger="manual".
Typing does not auto-highlight the first match. This one is a spec detail, not a library quirk: the WAI-ARIA APG defines four autocomplete behaviors, and react-aria-components implements "list autocomplete with manual selection" — filtering narrows the list, but nothing is auto-selected until the user presses ArrowDown (or clicks an option).1 So user.keyboard('astro{Enter}') does nothing in a test; you need user.keyboard('astro{ArrowDown}{Enter}'). This matters for real users too: don't assume Enter alone commits the top filtered result.
Step 6: Form integration
ComboBox already behaves like a form field once you give it a name. Set formValue="key" (the default) to submit the selected item's id, or formValue="text" to submit the visible text instead. Combined with isRequired and errorMessage, native HTML5 validation kicks in on submit:
<Combobox
label="Favorite framework"
items={frameworks}
name="framework"
isRequired
errorMessage={({ validationDetails }) =>
validationDetails.valueMissing ? 'Please choose a framework.' : 'Invalid selection.'
}
>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</Combobox>
Under the hood, ComboBox renders a hidden <input type="hidden" name="framework"> that mirrors the selected key, so a plain <form onSubmit> or a FormData read picks it up exactly like a native <select> — no controller/adapter needed to use it with fetch or a server action.
Step 7: Automated accessibility testing with jest-axe
Set up the test environment:
// src/test-setup.ts
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
globals: true
}
});
Now check for violations in both the closed and open states — a combobox's accessibility problems very often live specifically in the popup, so testing only the closed state misses them:
// src/Combobox.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { App } from './App';
describe('Combobox accessibility', () => {
it('has no axe violations when closed', async () => {
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when open with results', async () => {
const user = userEvent.setup();
const { container } = render(<App />);
await user.click(screen.getByRole('combobox', { name: /favorite framework/i }));
await user.keyboard('s');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Both checks pass with zero violations against the component built above — no manual aria-* wiring required, because the library owns it.
Step 8: Testing keyboard interaction
Screen-reader users and keyboard-only users never touch a mouse, so the tests that matter most simulate exactly that:
describe('Combobox keyboard interaction', () => {
it('filters options as the user types', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.type(input, 'sv');
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Svelte');
});
it('selects an option with ArrowDown + Enter and updates external state', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.click(input);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(screen.getByTestId('selection')).toHaveTextContent('vue');
expect(input).toHaveValue('Vue');
});
it('closes the popover on Escape without changing the selection', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.type(input, 'react');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(screen.getByTestId('selection')).toHaveTextContent('none');
});
});
{ArrowDown}{ArrowDown} moves virtual focus (via aria-activedescendant) past React onto Vue, the second item, and {Enter} commits it — the DOM focus never leaves the text input the entire time, which is exactly what the APG spec requires and exactly what a real screen reader relies on to keep announcing correctly.
Verification
Run the full suite:
npx vitest run
Expected output against the project built in this guide (the code shown above covers the 5 tests through Step 8 — the accessibility pair from Step 7 plus the three keyboard-interaction tests from Step 8):
✓ src/Combobox.test.tsx (5 tests) 388ms
Test Files 1 passed (1)
Tests 5 passed (5)
And confirm the component actually builds for production:
npx vite build
✓ built in 103ms
dist/assets/index-DaLH_xDj.js 382.97 kB │ gzip: 116.72 kB
Troubleshooting
TS2322: Type 'Key | null' is not assignable to type 'Key | null | undefined'. You imported Key from 'react' instead of 'react-aria-components'. React's own Key type and react-aria-components' re-exported Key type are structurally different, and TypeScript will not unify them. Fix: import { type Key } from 'react-aria-components' and use that everywhere you store a selected key.
ComboBoxItem is not exported from 'react-aria-components'. You copied an example from the react-aria.adobe.com docs site, which currently previews an upcoming API redesign. The 1.19.0 package on npm uses ListBoxItem for items, not ComboBoxItem. Always cross-check example code against the actual .d.ts files in your installed node_modules when a docs site and your package.json version might be out of sync.
The "no matches found" message never appears. You're filtering to zero results without allowsEmptyCollection on <ComboBox>. Without it, the popover simply doesn't open when the collection is empty, so your renderEmptyState content is unreachable.
user.click(input) doesn't open the popover in a test. This isn't a testing-library issue — it's the real, spec-compliant default. menuTrigger defaults to 'input' (opens on text edit), not 'focus' or 'manual'. Either type a character in your test, or set menuTrigger="focus" on the component if that's the UX you actually want.
Pressing Enter right after typing does nothing. Nothing is auto-highlighted after filtering (see Step 5). Send an {ArrowDown} first to move virtual focus onto the top result before {Enter}.
Your ref to <Combobox> comes back null, or you see Warning: Function components cannot be given refs. This is a React-version issue, not a react-aria-components one. Since React 19 (used throughout this guide, and confirmed with a direct test in the sandbox), function components can accept ref as a plain prop, so Combobox.tsx above needs no forwardRef at all — verified working, and the warning does not fire on 19.2.7. If your project is still on React 18 or earlier, you do need to wrap the component in forwardRef (React 19's ref-as-prop change isn't backported); skipping it there triggers exactly that warning and leaves the ref empty.
Next steps and further reading
If you're wiring this combobox into a larger form, see the React Hook Form + Zod tutorial or the TanStack Form + Zod tutorial for schema-driven validation patterns that compose with a native-form-shaped component like this one. For the testing setup itself, the Vitest coverage thresholds tutorial covers enforcing minimum coverage in CI once you have a suite like the one built here.
Footnotes
-
Combobox Pattern — ARIA Authoring Practices Guide, W3C WAI, fetched 2026-07-02. ↩ ↩2 ↩3
-
react-aria-components on npm — version 1.19.0, published 2026-06-18; peer dependencies and API surface verified against the installed package's
.d.tsfiles. ↩ ↩2 ↩3 -
@react-aria/test-utils on npm — version 1.0.0-rc.0, published 2026-05-28 (still the
latestdist-tag as of this writing). ↩