Accessible combobox combines input + dropdown with keyboard and screen reader support. It follows the WAI-ARIA Combobox pattern.
System design context: The combobox is one of the most complex accessible patterns because it involves:
1import { useState, useRef, useEffect, useCallback } from "react";23interface Option {4 id: string;5 label: string;6 description?: string;7}89interface ComboboxProps {10 options: Option[];11 onSelect: (option: Option) => void;12 placeholder?: string;13 label: string;14}1516export function Combobox({ options, onSelect, placeholder, label }: ComboboxProps) {17 const [inputValue, setInputValue] = useState("");18 const [filteredOptions, setFilteredOptions] = useState(options);19 const [activeIndex, setActiveIndex] = useState(-1);20 const [isOpen, setIsOpen] = useState(false);21 const [announce, setAnnounce] = useState("");22 const inputRef = useRef<HTMLInputElement>(null);23 const listRef = useRef<HTMLUListElement>(null);24 const optionRefs = useRef<(HTMLLIElement | null)[]>([]);25 const listboxId = "combobox-listbox";2627 useEffect(() => {28 const filtered = options.filter(opt =>29 opt.label.toLowerCase().includes(inputValue.toLowerCase())30 );31 setFilteredOptions(filtered);32 }, [inputValue, options]);3334 // Scroll active option into view35 useEffect(() => {36 if (isOpen && activeIndex >= 0 && activeIndex < filteredOptions.length) {37 optionRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });38 }39 }, [activeIndex, isOpen, filteredOptions.length]);4041 // Announce active option to screen readers42 useEffect(() => {43 if (isOpen && activeIndex >= 0 && activeIndex < filteredOptions.length) {44 const opt = filteredOptions[activeIndex];45 setAnnounce(`${opt.label}, ${activeIndex + 1} of ${filteredOptions.length}`);46 }47 }, [activeIndex, isOpen, filteredOptions]);4849 const handleKeyDown = useCallback((e: React.KeyboardEvent) => {50 switch (e.key) {51 case "ArrowDown":52 e.preventDefault();53 if (!isOpen) {54 setIsOpen(true);55 setActiveIndex(0);56 return;57 }58 setActiveIndex(i => Math.min(i + 1, filteredOptions.length - 1));59 break;60 case "ArrowUp":61 e.preventDefault();62 setActiveIndex(i => Math.max(i - 1, 0));63 break;64 case "Enter":65 e.preventDefault();66 if (activeIndex >= 0 && isOpen) {67 setInputValue(filteredOptions[activeIndex].label);68 onSelect(filteredOptions[activeIndex]);69 setIsOpen(false);70 setActiveIndex(-1);71 }72 break;73 case "Escape":74 setIsOpen(false);75 setActiveIndex(-1);76 break;77 case "Home":78 if (isOpen) {79 e.preventDefault();80 setActiveIndex(0);81 }82 break;83 case "End":84 if (isOpen) {85 e.preventDefault();86 setActiveIndex(filteredOptions.length - 1);87 }88 break;89 }90 }, [isOpen, activeIndex, filteredOptions, onSelect]);9192 return (93 <div className="combobox" role="combobox" aria-expanded={isOpen}>94 <label htmlFor="combobox-input" className="sr-only">{label}</label>95 <input96 ref={inputRef}97 id="combobox-input"98 type="text"99 value={inputValue}100 onChange={e => { setInputValue(e.target.value); setIsOpen(true); setActiveIndex(-1); }}101 onFocus={() => setIsOpen(true)}102 onKeyDown={handleKeyDown}103 role="combobox"104 aria-expanded={isOpen}105 aria-controls={listboxId}106 aria-activedescendant={107 activeIndex >= 0 ? `option-${filteredOptions[activeIndex]?.id}` : undefined108 }109 aria-autocomplete="list"110 aria-activedescendant=111 {activeIndex >= 0 ? `option-${activeIndex}` : undefined}112 placeholder={placeholder}113 />114 {isOpen && filteredOptions.length > 0 && (115 <ul116 ref={listRef}117 id={listboxId}118 role="listbox"119 aria-label={label}120 >121 {filteredOptions.map((opt, i) => (122 <li123 key={opt.id}124 ref={el => optionRefs.current[i] = el}125 id={`option-${opt.id}`}126 role="option"127 aria-selected={i === activeIndex}128 onMouseDown={e => {129 e.preventDefault(); // Prevent blur130 setInputValue(opt.label);131 onSelect(opt);132 setIsOpen(false);133 }}134 >135 {opt.label}136 {opt.description && (137 <span className="text-sm text-gray-500 ml-2">{opt.description}</span>138 )}139 </li>140 ))}141 </ul>142 )}143 {/* Screen reader announcements */}144 <div aria-live="polite" className="sr-only">{announce}</div>145 </div>146 );147}
Key ARIA attributes:
role="combobox" — identifies the input as a combobox.aria-expanded — indicates whether the listbox is open.aria-controls — links the input to the listbox by ID.aria-activedescendant — points to the currently highlighted option.aria-autocomplete="list" — announces that the input has autocomplete behavior.role="listbox" + role="option" — semantic list structure.Production pitfalls:
onClick instead of onMouseDown — clicking an option triggers a blur on the input first, closing the list before the click registers. Fix: use onMouseDown with e.preventDefault().aria-activedescendant — screen readers cannot track which option is highlighted.Scaling pattern: For large option lists (1000+), virtualize the listbox with react-window or react-virtuoso.
Monitoring: Track how many users select from the dropdown vs. type a custom value. High custom-value rates may indicate missing options.