---
title: Build an Accessible React Combobox with React Aria (2026)
description: 'Build a fully accessible React combobox with react-aria-components 1.19: correct ARIA wiring, keyboard nav, and a real jest-axe + Testing Library suite.'
url: https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial
language: en
date: '2026-07-02'
category: frontend
tags:
  - react
  - accessibility
  - react-aria
  - combobox
  - frontend
  - typescript
  - testing
markdown_url: https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Build an Accessible React Combobox with React Aria (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](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) 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-components` `ComboBox` using 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 `menuTrigger` option 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>` with `isRequired` and `FieldError`
- 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 `engines` requirement — Vitest 4 accepts `^20.0.0 || ^22.0.0 || >=24.0.0`, so either LTS line works)
- `react` and `react-dom` **19.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:

```bash
npm install react-aria-components@1.19.0
```

And the dev/test toolchain used in this guide:

```bash
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 `div`s yourself?

The [WAI-ARIA Authoring Practices Guide (APG) Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) 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.[^apg]

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.[^apg][^rac]

## Step 2: Build the base ComboBox

Create `Combobox.tsx`. This wraps `react-aria-components`' `ComboBox` in a small, reusable, generic component:

```tsx
// 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 `ListBoxItem`s, 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.[^rac]

## Step 3: Use it with a real list

```tsx
// 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:

```css
/* 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).[^apg] 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:

```tsx
<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:

```ts
// src/test-setup.ts
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import { toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);
```

```ts
// 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:

```tsx
// 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:

```tsx
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:

```bash
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:

```bash
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.

## FAQ

**Does react-aria-components require React 19?** No. Its declared peer range is `^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1` — 16.8 and up, including 17 and 19 release-candidate builds — so you can adopt it in an existing React 16.8+, 17, or 18 codebase without an upgrade.[^rac]

**Do I need `@react-aria/test-utils` to test this?** No — it's still a release candidate (`1.0.0-rc.0` as of this writing) and not required. Standard `@testing-library/react` plus `@testing-library/user-event` and `jest-axe`, as used throughout this guide, is enough for full coverage including accessibility checks.[^testutils]

**Why did my axe check pass but a real screen reader still sounds wrong?** Automated tools like `axe-core` catch structural and contrast issues (missing labels, invalid ARIA, contrast ratios) but can't verify announcement *wording* or timing. Automated testing is necessary, not sufficient — pair it with at least one manual pass using VoiceOver, NVDA, or JAWS before shipping a widget like this to production.

**Does this work in a Next.js App Router / React Server Components project?** Yes, but `ComboBox` and the other interactive react-aria-components exports use React state and hooks internally, so any file that imports them needs a `'use client'` directive at the top — the same rule that applies to any component using local state or event handlers. (The installed type declarations for these exports also carry an `import 'client-only'` marker, though that specific package only appears in the types, not the runtime bundle — the real reason you need `'use client'` is the hook usage, not that marker.) Import the `Combobox` wrapper only from client components.

## Next steps and further reading

If you're wiring this combobox into a larger form, see the [React Hook Form + Zod tutorial](https://nerdleveltech.com/react-hook-form-zod-resolver-tutorial) or the [TanStack Form + Zod tutorial](https://nerdleveltech.com/tanstack-form-react-zod-validation-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](https://nerdleveltech.com/vitest-coverage-thresholds-fail-ci-tutorial) covers enforcing minimum coverage in CI once you have a suite like the one built here.

[^apg]: [Combobox Pattern — ARIA Authoring Practices Guide, W3C WAI](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), fetched 2026-07-02.
[^rac]: [react-aria-components on npm](https://www.npmjs.com/package/react-aria-components) — version 1.19.0, published 2026-06-18; peer dependencies and API surface verified against the installed package's `.d.ts` files.
[^testutils]: [@react-aria/test-utils on npm](https://www.npmjs.com/package/@react-aria/test-utils) — version 1.0.0-rc.0, published 2026-05-28 (still the `latest` dist-tag as of this writing).
