Foundational patterns for building accessible autocomplete/combobox components with state management, ARIA patterns, keyboard navigation, async suggestions, and framework-agnostic implementation. Use when building autocomplete inputs, command palettes, search inputs, select replacements, or any dropdown with keyboard navigation and suggestions.
Build accessible autocomplete/combobox components using proven patterns from production implementations.
Every library (Downshift, Headless UI, Ariakit, Algolia) converges on remarkably similar state shapes. Understanding this canonical model lets you implement in any framework:
interface FilterInputState {
// Interaction state (the state machine)
status: 'idle' | 'focused' | 'suggesting' | 'loading';
// Input state
inputValue: string;
cursorPosition: number;
// Token state (for multi-select, see webdev-token-input)
tokens: FilterToken[];
activeTokenIndex: number | null;
// Suggestion state
suggestions: Suggestion[];
highlightedIndex: number; // -1 means no highlight
// Async coordination
lastFetchedQuery: string; // Prevents redundant fetches
pendingRequestId: number; // Race condition prevention
}
The highlightedIndex deserves special attention. This is "virtual focus"—the visually highlighted suggestion—while DOM focus stays on the input. The W3C APG mandates this pattern: you communicate the focused option to screen readers via aria-activedescendant rather than actually moving focus. This lets users continue typing while navigating suggestions.
Downshift's key innovation was the state reducer pattern, which lets you intercept and modify any state transition. When you need the menu to stay open after selection (common for multi-select), you override just that transition rather than forking the library:
stateReducer: (state, { type, changes }) => {
if (type === 'ItemClick' || type === 'InputKeyDownEnter') {
return { ...changes, isOpen: true, inputValue: '' };
}
return changes;
}
This pattern allows customization without fighting the library's internal logic.
Required structure with aria-activedescendant for virtual focus:
<input
role="combobox"
aria-expanded="true"
aria-controls="suggestions"
aria-activedescendant="option-2"
aria-autocomplete="list"
/>
<ul role="listbox" id="suggestions">
<li role="option" id="option-1" aria-selected="false">First</li>
<li role="option" id="option-2" aria-selected="true">Second</li>
</ul>
Critical: Browsers don't auto-scroll when aria-activedescendant changes. Manually call element.scrollIntoView({ block: 'nearest' }) when highlighting changes. This is the most common accessibility bug in custom implementations.
The most reusable pattern across React, Svelte, Solid, and vanilla JS is prop-getters: functions that return objects containing event handlers and ARIA attributes to spread onto your elements. Downshift popularized this; Headless UI, Ariakit, and Kobalte all use variants.
function getInputProps(userProps = {}) {
return {
role: 'combobox',
'aria-expanded': state.isOpen,
'aria-controls': 'listbox-id',
'aria-activedescendant': state.highlightedIndex >= 0
? `option-${state.highlightedIndex}`
: undefined,
'aria-autocomplete': 'list',
onKeyDown: composeEventHandlers(userProps.onKeyDown, handleKeyDown),
onChange: composeEventHandlers(userProps.onChange, handleChange),
onFocus: composeEventHandlers(userProps.onFocus, handleFocus),
onBlur: composeEventHandlers(userProps.onBlur, handleBlur),
...userProps
};
}
function composeEventHandlers(...handlers) {
return (event) => {
for (const handler of handlers) {
if (event.defaultPrevented) break;
handler?.(event);
}
};
}
This pattern solves multiple problems: users can add their own handlers that run before yours, your handlers can call preventDefault() to stop user handlers, and all the ARIA attributes are correct by default. The same getInputProps/getMenuProps/getItemProps shape works in any framework—you're returning plain objects, not JSX.
Race conditions are the #1 bug source in custom autocomplete implementations. User types "java", request fires, user types "script", first request returns and overwrites the correct "javascript" results. Three patterns prevent this:
// Pattern 1: Request ID tracking (simplest)
let latestRequestId = 0;
async function fetchSuggestions(query) {
const requestId = ++latestRequestId;
const results = await api.search(query);
if (requestId === latestRequestId) {
setState({ suggestions: results });
}
}
// Pattern 2: AbortController (cleanest)
useEffect(() => {
const controller = new AbortController();
api.search(query, { signal: controller.signal }).then(setSuggestions);
return () => controller.abort();
}, [query]);
// Pattern 3: XState (most robust)
// Transitioning out of "fetching" state automatically cancels invoked services
Algolia's research shows a hybrid approach works better—throttle for the first few characters (eager feedback), debounce after (patient waiting):
function handleInput(query) {
if (query.length < 3) throttledSearch(query); // Immediate feedback
else debouncedSearch(query); // Wait for typing to settle
}
Tuning: 200ms for desktop, 300ms for mobile.
Prevent redundant fetches by storing lastFetchedQuery in state:
if (query === state.lastFetchedQuery) return;
For context-dependent suggestions (different values for status: vs assignee:), maintain separate caches keyed by field name.
Problem: Using blur to close menu fires before click events, preventing selection.
Solution: Close on Tab keydown and click-outside detection, never on blur.
Problem: Framework re-renders reset cursor to end of input.
Solution: Store selectionStart before update, restore in microtask:
const cursorPos = inputRef.current.selectionStart;
setState({ value });
requestAnimationFrame(() => {
inputRef.current.setSelectionRange(cursorPos, cursorPos);
});
Problem: Debounced handlers capture old state. When the debounced function fires, it sees the state from when it was created, not current state.
Solution: Use refs for values that debounced functions need to read:
const queryRef = useRef();
queryRef.current = query;
const debouncedFetch = useMemo(
() => debounce(() => fetchSuggestions(queryRef.current), 300),
[]
);
Problem: Event handlers between input and container cause double-firing.
Solution: The prop-getter pattern with composeEventHandlers solves this by checking defaultPrevented before calling each handler.
getInputProps, getListboxProps, getOptionPropsThe fundamental insight: automated tools like axe-core catch only ~30% of real accessibility issues.
useCombobox), cmdk (command palettes), Headless UI ComboboxCombobox is a composite widget: one tab stop containing multiple interactive elements. The component owns a mini focus system (virtual focus via aria-activedescendant and arrow keys) while participating in the page's tab order as a single unit. Get this mental model right and the implementation details follow naturally.
npx skills add ozten/webdev-combobox-autocomplete下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer