Type, Arrow, Select: Building an Accessible Combobox in React
{
{
“title”: “Type, Arrow, Select: Building an Accessible Combobox in React”,
“summary”: “Learn how to build a fully accessible combobox and autocomplete component from scratch in React and TypeScript, strictly adhering to WAI-ARIA authoring practices.”,
“tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”],
“body”: “# Type, Arrow, Select: Building an Accessible Combobox in React\n\nBuilding a combobox—often referred to as an autocomplete or typeahead component—is one of the most deceptively complex UI challenges in web development. At first glance, it looks simple: an input field combined with a dropdown list. However, under the hood, a production-grade combobox requires meticulous state management, robust keyboard navigation, and strict adherence to WAI-ARIA authoring practices to ensure it works seamlessly for screen reader and keyboard-only users.\n\nIn this post, we will build a fully accessible combobox component from scratch using React, TypeScript, and modern accessibility patterns.\n\n—\n\n## Understanding the WAI-ARIA Combobox Pattern\n\nThe WAI-ARIA 1.2 specification defines a combobox as an input widget that controls another element, such as a listbox or grid, that can dynamically pop up to help the user set the value of the input.\n\nTo make this accessible, we must establish a clear relationship between the input and the popup list using specific ARIA attributes:\n\n* role=\"combobox\": Applied to the input element to identify its role.\n* aria-expanded: A boolean (true or false) indicating whether the popup list is currently visible.\n* aria-haspopup: Set to listbox to inform assistive technologies that the input triggers a listbox popup.\n* aria-controls: Points to the ID of the popup listbox element.\n* aria-activedescendant: Points to the ID of the currently highlighted option within the listbox, allowing screen readers to announce focused options without moving the physical DOM focus away from the input.\n\n—\n\n## Setting Up the Types and Component Shell\n\nLet’s start by defining our TypeScript interfaces. We need a flexible option structure and props that allow consumers to pass custom data and render functions.\n\ntsx\nimport React, { useState, useRef, useEffect, useId } from 'react';\n\nexport interface ComboboxOption {\n id: string;\n label: string;\n value: string;\n [key: string]: any;\n}\n\ninterface ComboboxProps {\n options: ComboboxOption[];\n value: string;\n onChange: (value: string) => void;\n placeholder?: string;\n label: string;\n}\n\n\nNext, let’s establish the core state variables inside our React component. We need to track:\n* Whether the dropdown is open.\n* The current query string in the input.\n* The index of the currently highlighted option for keyboard navigation.\n\ntsx\nexport const AccessibleCombobox: React.FC<ComboboxProps> = ({\n options,\n value,\n onChange,\n placeholder = 'Search...',\n label,\n}) => {\n const [isOpen, setIsOpen] = useState(false);\n const [query, setQuery] = useState(value);\n const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);\n\n const inputRef = useRef<HTMLInputElement>(null);\n const listboxRef = useRef<HTMLUListElement>(null);\n\n const comboboxId = useId();\n const listboxId = `${comboboxId}-listbox`;\n const labelId = `${comboboxId}-label`;\n\n // Filter options based on user input\n const filteredOptions = options.filter((option) =>\n option.label.toLowerCase().includes(query.toLowerCase())\n );\n\n // ...component logic continues\n};\n\n\n—\n\n## Managing Keyboard Navigation\n\nA truly accessible combobox must be navigable entirely via the keyboard. Users should expect standard behaviors:\n\n1. Arrow Down / Arrow Up: Opens the listbox (if closed) and moves the highlight index down or up through the options.\n2. Enter: Selects the currently highlighted option and closes the listbox.\n3. Escape: Closes the listbox and reverts or clears the input.\n4. Alt + Arrow Down: Opens the listbox without changing focus.\n\nHere is how we implement this inside a robust onKeyDown handler on the input:\n\ntsx\nconst handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n if (!isOpen) {\n setIsOpen(true);\n } else {\n setHighlightedIndex((prev) =>\n prev < filteredOptions.length - 1 ? prev + 1 : 0\n );\n }\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n if (!isOpen) {\n setIsOpen(true);\n } else {\n setHighlightedIndex((prev) =>\n prev > 0 ? prev - 1 : filteredOptions.length - 1\n );\n }\n break;\n\n case 'Enter':\n e.preventDefault();\n if (isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {\n selectOption(filteredOptions[highlightedIndex]);\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n setIsOpen(false);\n setHighlightedIndex(-1);\n break;\n\n default:\n break;\n }\n};\n\n\n—\n\n## Leveraging aria-activedescendant\n\nIn older or poorly implemented autocomplete components, developers often move the actual DOM focus to the list items inside the dropdown. This is an anti-pattern because it detaches the focus from the input, making it difficult for users to continue typing.\n\nInstead, WAI-ARIA 1.2 mandates aria-activedescendant. The DOM focus remains on the <input> element at all times. When an option is highlighted, we assign its DOM node a unique ID and pass that ID to the input’s aria-activedescendant attribute. Screen readers will automatically announce the content of the referenced element.\n\ntsx\nconst getOptionId = (index: number) => `${comboboxId}-option-${index}`;\n\n// Inside the input JSX:\n<input\n ref={inputRef}\n type=\"text\"\n role=\"combobox\"\n aria-expanded={isOpen}\n aria-haspopup=\"listbox\"\n aria-controls={listboxId}\n aria-autocomplete=\"list\"\n aria-activedescendant={\n isOpen && highlightedIndex >= 0 ? getOptionId(highlightedIndex) : undefined\n }\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setIsOpen(true);\n setHighlightedIndex(0);\n }}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n/>\n\n\n—\n\n## Assembling the Listbox and Options\n\nNow let’s render the listbox container and its option elements. Each option must have role=\"option\", a unique ID matching our helper function, and an aria-selected state.\n\ntsx\nconst selectOption = (option: ComboboxOption) => {\n setQuery(option.label);\n onChange(option.value);\n setIsOpen(false);\n setHighlightedIndex(-1);\n inputRef.current?.focus();\n};\n\nreturn (\n <div className=\"relative w-full max-w-sm\">\n <label id={labelId} className=\"block text-sm font-medium text-gray-700 mb-1\">\n {label}\n </label>\n \n <div className=\"relative\">\n <input\n // ...input attributes from previous section\n />\n\n {isOpen && filteredOptions.length > 0 && (\n <ul\n ref={listboxRef}\n id={listboxId}\n role=\"listbox\"\n aria-labelledby={labelId}\n className=\"absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm\"\n >\n {filteredOptions.map((option, index) => {\n const isHighlighted = index === highlightedIndex;\n return (\n <li\n key={option.id}\n id={getOptionId(index)}\n role=\"option\"\n aria-selected={isHighlighted}\n onClick={() => selectOption(option)}\n className={`cursor-default select-none relative py-2 pl-3 pr-9 ${\n isHighlighted ? 'bg-indigo-600 text-white' : 'text-gray-900'\n }`}\n >\n {option.label}\n </li>\n );\n })}\n </ul>\n )}\n </div>\n </div>\n);\n\n\n—\n\n## Handling Click-Outside and Focus Loss\n\nA complete combobox must close gracefully when the user clicks outside the component or tabs away to another part of the page.\n\ntsx\nuseEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n inputRef.current &&\n !inputRef.current.contains(event.target as Node) &&\n listboxRef.current &&\n !listboxRef.current.contains(event.target as Node)\n )\n {\n setIsOpen(false);\n setHighlightedIndex(-1);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n}, []);\n\n\n> Pro Tip: Avoid closing the listbox immediately on onBlur of the input without checking where the focus is moving. If the user clicks an option in the listbox, the input will briefly lose focus before the click event fires. Using a document-level mousedown listener prevents the dropdown from prematurely disappearing before a click registration.\n\n—\n\n## Conclusion\n\nBuilding an accessible combobox requires moving beyond basic UI styling and paying close attention to assistive technology workflows. By implementing WAI-ARIA 1.2 patterns like aria-activedescendant, maintaining robust keyboard navigation, and carefully handling focus states, you ensure that your React applications are inclusive for all users.\n\nFeel free to take this foundation and extend it with asynchronous data fetching, multiselect capabilities, or custom render props for rich item templates!"\n}