Autocomplete looks like a solved problem until you try to build one properly. You start with <input list> and a <datalist>, feel clever for about ten minutes, and then a designer asks to style the dropdown, or highlight the matched letters, or show a flag next to each row, and you discover that native datalist gives you exactly none of that. The filtering logic isn't yours (browsers disagree on prefix vs substring), you can't touch the styling, and the keyboard and screen-reader behaviour varies wildly between Chrome, Firefox and Safari. So for anything real, you build a proper combobox: a text input plus a listbox you own end to end.
Here's the whole thing, built in about 110 lines of vanilla JS over ~200 countries. It filters as you type, highlights the match, navigates by keyboard with wrap-around, selects on Enter or click, closes on Esc or an outside click, and wires up the full WAI-ARIA combobox pattern.
Live demo: https://dev48v.infy.uk/design/day23-autocomplete.html
The data flow
A combobox is one input tied to an array. The input holds the query; the array is the source of truth; the visible list is just a projection of the filtered array. Keep one piece of state — results, the filtered array — and re-render the DOM from it. The DOM list is never the truth, only a picture of it. This matters because every later feature (navigation, selection) reduces to an index into that array. Track one integer and you've tracked everything.
let results = COUNTRIES.slice();
input.addEventListener("input", debounce(() => {
results = filter(input.value);
render(input.value);
}, 120));
Why debounce
An input event fires on every keystroke. A fast typist generates eight to ten a second, so filtering and re-rendering on each one is wasteful. Locally it's just a nicety. But the moment your data comes from a server, the debounce becomes essential: without it you fire one HTTP request per letter, flooding the API and inviting out-of-order responses. Waiting ~120ms after the user pauses collapses a burst of keystrokes into a single run.
function debounce(fn, ms){
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
Filtering and highlighting
Filtering is a case-insensitive substring test — lower-case the query and each name, keep the ones that include the query. An empty query returns everything so focusing the field shows the full list. To highlight, find where the query lands in the name and wrap those characters in <mark>. Because you're injecting HTML, escape every slice you didn't match — otherwise a stray character (or a malicious remote result) breaks the markup or injects a script.
The active-descendant trick
This is the part people get wrong. When you press the down arrow, focus does not move into the list. It stays on the input the entire time. Instead, the input carries aria-activedescendant pointing at the id of the "active" option, and that option gets aria-selected="true". This is the whole combobox pattern: the user keeps typing while a virtual cursor moves through a role="listbox" of role="option" rows. Screen readers announce the active option aloud as it moves, without real focus ever leaving the field.
Keyboard navigation hangs off one integer, active:
if (e.key === "ArrowDown"){ e.preventDefault(); setActive((active + 1) % n); }
if (e.key === "ArrowUp"){ e.preventDefault(); setActive((active - 1 + n) % n); }
The modulo gives you wrap-around — last item back to first, and first up to last — which feels natural. preventDefault() stops the caret jumping to the ends of the text. After each move, repaint aria-selected, update aria-activedescendant, and scrollIntoView({ block: "nearest" }) so the active row is never hidden below the fold.
Selecting and dismissing
Enter commits the active option (guarded so Enter on nothing does nothing). A click commits the clicked row — but listen on mousedown, not click, because a plain click first blurs the input and can close the list before the click registers. Esc closes without choosing. A single document-level pointerdown listener closes the list when the target is outside the wrapper, guarded by wrap.contains(e.target) so inside clicks are ignored. Closing is one function: hide the list, reset active to -1, set aria-expanded="false", clear aria-activedescendant. Keep the ARIA state honest and the widget is usable without ever seeing it.
Going remote
Swap the local filter for a fetch and the debounce earns its keep. The subtle bug is ordering: type "in" then "ind", and if the slow "in" response lands after "ind" it clobbers the correct list. Tag each request with an incrementing id and drop any response that isn't the latest. Everything else stays the same.
That's the entire combobox — the same thing Downshift, Headless UI and React-Select do underneath.
Top comments (0)