Design an autocomplete
A reusable text box that suggests matches as people type, for product search, a people picker, or a country field. The hard parts are deciding when to ask the server, never showing answers for an older query, caching what was already seen, the combobox pattern for keyboard and screen reader users, a popup that fits any screen, and text input in every language without slowing down typing.
The question
Design an autocomplete component for our design system. As someone types into a text box, a list of suggestions opens under it, and they can pick one with a mouse, a tap, or the keyboard. Think of the search box at the top of a shopping site, or the people picker you use to share a document.
Three teams will use it first: product search over a few million items on a server, a people picker over the company directory, and a country field with a short fixed list. It has to feel instant, never show the wrong results, and work for keyboard and screen reader users in every language we ship. Walk me through how you would build it.
Real-life examples: Google search box, Amazon product search, GitHub's "Go to file", The people picker in Google Docs sharing, Airbnb's location search
Requirements: what to ask before drawing anything
An autocomplete looks like a text box and a list. That's why people underestimate it. Behind the box sit four separate problems: a reusable component API, a network layer that copes with fast typing and slow answers, an accessibility pattern that most developers get wrong, and text handling that has to work for Japanese, Arabic, and French as well as English. In 45 minutes you can go deep on two or three of them, so the first five minutes decide which.
Ask questions whose answers change the design: where the data comes from, what one suggestion looks like, what happens when the user picks one, and how fast it must feel on which devices. The table shows the answer this guide assumes for each question, and which part of the design moves if the interviewer says something else.
| Question | Answer we assume | What it changes |
|---|---|---|
| Where does the data come from? | Both. A server for products (2 million items) and people (80,000), and small local lists such as 250 countries. | The data source must be pluggable. Server data needs debounce, race handling, and a cache; local data needs fast filtering in the browser. |
| What does one suggestion look like? | It depends on the team: a plain query for search, an avatar and a name for people, a photo, a title, and a price for products. | Rendering has to be the caller's job, through render props or slots. |
| What happens on pick? | Product search goes to the product page or runs a search. The people picker fills a form field with the person's id. | Two modes: free text, where any typed text is a valid answer, and select-only, where only a listed option is. |
| How fast must it feel? | Suggestions within 500 ms of the last keystroke at the 75th percentile, and in the same frame for anything seen before. | The debounce numbers, the cache, and a warm connection to the API. |
| Which devices and input methods? | Desktop and phones; mouse, touch, keyboard, screen readers, and input method editors for Chinese, Japanese, and Korean. | The ARIA combobox pattern, popup placement above on-screen keyboards, and composition events. |
| How much traffic? | The box is on every page. At peak, about 5,000 keystrokes per second across all users. | We can't send one request per keystroke; the request rate is a real cost. |
| One value or many? | One. Picking several people as chips is the tag input problem. | Keeps the state small: one chosen item. |
Functional requirements
- Show suggestions that match the text as the user types, from a server or from a local list.
- Let the user move through suggestions and pick one with the keyboard, a mouse, or a tap.
- Let each team decide how a suggestion looks and where its data comes from, without changing the component.
- Show useful content on focus before anything is typed: recent searches and popular items.
- Show clear loading, empty, error, and offline states, and never block typing while doing so.
- Work with both free text and select-only fields, inside or outside a form.
Non-functional requirements, with numbers
Non-functional requirements describe how well the component works, not what it does. Give each one a number, because a number can be tested and a word like "fast" can't.
- Fast. Suggestions show within 500 ms of the last keystroke at the 75th percentile on a 4G phone. Anything already seen shows in the same frame as the keystroke.
- Responsive typing. Each keystroke paints within 50 ms on a mid-range phone, so INP, Interaction to Next Paint, stays well under the 200 ms that Google calls good.
- Correct. The list always belongs to the text in the box. The number of times the list shows results for an older query is zero.
- Frugal. About one request per pause in typing. Typing "wireless headphones" sends 2 to 4 requests, not 19.
- Accessible. Meets WCAG 2.2 level AA and follows the WAI-ARIA combobox pattern. Everything works with the keyboard alone, and screen reader users hear the highlighted option and how many results there are.
- Small. Under about 10 KB of compressed JavaScript, because it ships on every page.
- International. Works with input method editors, accents, and right-to-left scripts.
Out of scope, said out loud
Naming what you won't design shows judgment and gives the interviewer a chance to redirect you early. For this problem, four things are usually out.
- The search engine and its ranking. The server decides what matches and in what order. We define the API we need from it.
- Spelling correction and "Did you mean". They're server features; the component only shows what comes back.
- Picking many values as chips. That's the tag input, a related but different component.
- The full search results page that opens after the user presses Enter.
Check your understanding · Exercise 1
Which answers from the interviewer would change this design the most? Select all that apply.
What makes an autocomplete hard
Before choosing anything, name the forces. Each decision later in this guide comes from one of these seven. If you can list them, you can work out most of the design live in the interview instead of reciting it.
- People type faster than the network answers. Most people leave 100 to 250 ms between keystrokes inside a word. A suggestion request on a phone takes 100 to 600 ms. So a new keystroke usually arrives before the last answer does.
- Answers come back out of order. Each request takes a different path and a different amount of server work. Short queries such as "l" match more items and are often slower to rank than long ones.
- Every keystroke is an interaction. INP measures how long the page takes to paint after a key press. Anything the component does on each key adds to it.
- Focus stays in the text box. The user moves through the list while still typing, so the usual rule of moving focus to what's active doesn't apply. This is why the ARIA pattern is unusual.
- The popup floats over everything. It must escape scroll containers and
overflow: hiddenparents, fit on screen, and stay visible above a phone's on-screen keyboard. - Text is harder than it looks. Chinese and Japanese users compose words through an input method before they're final. "cafe" should find "Café". Arabic and Hebrew run right to left.
- Many teams, many needs. Products, people, and countries have different data, looks, and rules. One component has to serve all of them without turning into 40 props.
The first two forces together cause the most famous autocomplete bug. Watch what happens if the component sends a request on every keystroke and shows whatever answer arrives.
The user types "laptop" in about 800 ms. Six requests go out. They come back in the order the network decides, and the last one to arrive is for "lapto", so the box says "laptop" while the list shows results for "lapto".
The rest of the guide takes the forces one at a time: the component API for many teams, when to fetch, stale responses, caching, matching and highlighting, the combobox pattern and keyboard, positioning, long lists, states, international text, and performance. Then we put the parts together.
Think first
Suppose the network always answered in the order requests were sent. Would you still need anything beyond "fetch on every keystroke and show the answer"?
Yes. Ordered answers fix the wrong-results bug but not the cost. At 5,000 keystrokes per second across all users, one request per keystroke is 5,000 requests per second, and most answers are thrown away before anyone reads them. On a phone, each one also costs battery and data.
The user would also see the list flicker through results for "l", "la", "lap", and so on. That's noise, and each new list is a render that competes with the next keystroke. So you'd still want to wait for a pause, which is debouncing.
One component, many teams: the API
Start with the API, because every later decision has to fit inside it. The naive version is a component that knows how to search: Autocomplete url="/api/search" labelKey="name". It fetches the URL with the query, reads name from each result, and shows it as text.
Then the teams arrive. The people picker needs an auth header and a POST body. Product search wants a photo and a price in each row. The country field has no URL at all, and analytics wants to know when a suggestion was picked by keyboard.
Each need becomes a prop: headers, method, imageKey, priceKey, items, onPickWithKeyboard. A year later the component has 40 props, half of them interact in ways nobody tested, and the next team still can't do what it needs.
Decision How should teams plug their data and their looks into one autocomplete?
What we pick: Build the headless hook as the core, and ship the data-source-plus-render-props component on top of it as the default. Most teams use the component and never think about ARIA. A team with an unusual layout, such as a command palette, uses the hook with its own markup and still gets the same timing, races, caching, and keyboard model. Libraries such as Downshift and React Aria are built this way for the same reason.
Controlled and uncontrolled, for two values
An autocomplete holds two separate values, and mixing them up causes most API bugs. The input value is the text in the box. The value is the item the user picked, or null.
Typing "lap" changes the input value but not the value. Picking "Laptop stand" sets the value to that product and usually sets the input value to its label.
Each value can be controlled or uncontrolled. Controlled means the parent owns it: it passes value and gets onChange, and the component shows whatever the parent says. Uncontrolled means the component owns it and the parent only gives a starting defaultValue.
Product search controls the input value so it can stay in sync with ?q= in the URL. The people picker controls the value because its form needs the person's id. The country field controls neither. A component must never switch between the two modes during its life, and it should warn in development if a prop goes from undefined to defined.
Events, each with a reason
Events tell the caller what happened and why. The reason matters: an analytics team wants to know if a pick came from the keyboard or a click, and a parent that controls the input value must not treat the text change caused by a pick as new typing, or it will start another search for the label it just chose.
type Source<T> = (query: string, ctx: { signal: AbortSignal }) => Promise<readonly T[]>;
type AutocompleteProps<T> = {
label: string;
source: Source<T> | readonly T[];
getKey: (item: T) => string;
getLabel: (item: T) => string;
renderOption?: (item: T, state: { highlighted: boolean; query: string }) => ReactNode;
renderEmpty?: (query: string) => ReactNode;
// The picked item: controlled (value) or uncontrolled (defaultValue).
value?: T | null;
defaultValue?: T | null;
onChange?: (item: T | null, reason: "keyboard" | "pointer" | "clear") => void;
// The typed text: controlled (inputValue) or uncontrolled (defaultInputValue).
inputValue?: string;
defaultInputValue?: string;
onInputChange?: (text: string, reason: "typing" | "pick" | "clear") => void;
onOpenChange?: (open: boolean) => void;
freeText?: boolean; // can the user submit text that isn't an option?
minChars?: number; // default 2 for a function source, 0 for an array
debounceMs?: number; // default 200
};
Two small choices make the API easier to live with. getKey gives every item a stable id, which React needs for keys and ARIA needs for option ids. getLabel gives the plain text the component uses for matching, for the box after a pick, and for what a screen reader says. Rich rows come from renderOption, but the label is always plain text.
Check your understanding · Exercise 2
The product team wants rows with a photo and a price, and their API needs an auth header. Which parts of our API let them do this without changing the component? Select all that apply.
When to fetch: debounce, throttle, and minimum characters
The naive component sends a request on every input event. Typing "wireless headphones" is 19 keystrokes, so it's 19 requests, and the user reads the results of maybe two of them: one after "wireless" while they glance at the list, and one at the end. Across the site, 5,000 keystrokes per second become 5,000 requests per second. The phone pays too: each request wakes the radio, and each answer is parsed and rendered while the user is still typing.
Two classic tools limit how often a function runs. Throttle runs it at most once per time window while events keep coming: with a 250 ms window, steady typing sends about 4 requests per second. Debounce waits until events stop for a set time, then runs once with the latest value: with a 200 ms wait, it sends nothing while the user types quickly and one request when they pause. Type in the lab below, change the settings, and compare how many requests each strategy would send.
Decision When should the autocomplete ask the server for suggestions?
What we pick: Cache first, then a 200 ms debounce with a 1,000 ms maximum wait. The cache makes the common Backspace and retype cases free and instant, the debounce keeps requests near one per pause, and the maximum wait means a fast typist still sees suggestions within a second.
The numbers, and where they come from
| Setting | Value | Why |
|---|---|---|
| Debounce wait | 150 to 250 ms; we use 200 | Most gaps between keys inside a word are 100 to 250 ms. A gap longer than about 200 ms usually means the user is reading or thinking. |
| Maximum wait | About 1,000 ms | A fast typist with no pauses still gets a fresh list every second. |
| Minimum characters | 2 for a server source, 1 for Chinese or Japanese, 0 for a local list | One Latin letter matches too much to be useful and is the most expensive query to rank. One Chinese character carries a whole word's meaning. |
| Network budget | About 300 ms at the 75th percentile | 200 ms of waiting plus 300 ms of network stays inside the 500 ms requirement. |
| Loading indicator delay | 300 ms | Most answers arrive sooner, and a spinner that flashes for 80 ms is noise. |
Normalize the text before deciding anything. Trim spaces at both ends, collapse runs of spaces, and lowercase it. Then "lap " and "lap" are the same query, so adding a space doesn't fire a request.
If the normalized query hasn't changed since the last request, do nothing. If it's shorter than the minimum, close the popup or show the initial suggestions instead of asking the server.
function useDebouncedQuery(text: string, waitMs = 200, maxWaitMs = 1000) {
const [query, setQuery] = useState(text);
const firstChange = useRef<number | null>(null);
useEffect(() => {
const now = performance.now();
firstChange.current ??= now;
const waited = now - firstChange.current;
const delay = Math.max(0, Math.min(waitMs, maxWaitMs - waited));
const id = setTimeout(() => {
firstChange.current = null;
setQuery(text);
}, delay);
return () => clearTimeout(id);
}, [text, waitMs, maxWaitMs]);
return query;
}
Check your understanding · Exercise 3
A user types "iphone" quickly, pauses for 600 ms, then types " case" quickly and stops. With a 200 ms debounce, a 2-character minimum, and an empty cache, which statements are true? Select all that apply.
Stale responses: making sure the list matches the box
Debounce makes races rarer, not impossible. A user types "lap", pauses for 250 ms, so a request goes out, then types "top". A second request goes out for "laptop".
The server is slow for "lap" because it has 40,000 matches to rank, so it takes 600 ms, while "laptop" takes 150 ms. The "laptop" answer arrives first and the list is right. Then the "lap" answer arrives and the naive code shows it. The box says "laptop" and the list shows lap desks and lapel pins.
This is a race condition: the result depends on which of two things finishes first, and nothing controls the order. The naive code has the bug because it trusts every answer: source(query).then(setItems). The fix is to decide, when an answer arrives, whether it still belongs to the current text. Try the lab: send both requests, deliver the answers in either order, and switch the guard.
Decision How do we stop an old answer from replacing a newer one?
What we pick: Use both cancel and request ids. Abort saves the bandwidth and parsing, and it's the polite thing to do with the caller's request. The id check is what makes the list correct, because it holds even when a source ignores the signal or an answer slips through before the abort. Treat an abort as normal, not as an error: never show an error state for it.
const latest = useRef(0);
const inFlight = useRef<AbortController | null>(null);
async function run(query: string) {
inFlight.current?.abort();
const id = ++latest.current;
const hit = cache.get(key(query));
if (hit) return dispatch({ type: "success", query, items: hit });
const controller = new AbortController();
inFlight.current = controller;
dispatch({ type: "loading", query });
try {
const items = await source(query, { signal: controller.signal });
cache.set(key(query), items);
if (id !== latest.current) return; // a newer query took over
dispatch({ type: "success", query, items });
} catch (error) {
if (controller.signal.aborted || id !== latest.current) return; // not an error
dispatch({ type: "error", query, error });
}
}
Notice two details in the code. The cache stores the answer even when it's too late to show, because the user may come back to that query with Backspace. And the cache path bumps the id too. Without that, this sequence breaks: "laptop" goes to the network, the user deletes back to "lap", which is cached and shows at once, and then the "laptop" answer arrives and replaces it.
Cancel at the other moments that end a query as well: when the user picks an option, presses Escape, clears the box, or the component unmounts. In React, the effect that starts a request should return a cleanup that aborts it. Since React 18, setting state after unmount no longer warns and does nothing, but the download and the parsing are still wasted work.
Check your understanding · Exercise 4
The component aborts the previous request and also checks request ids. Which situations would show wrong results if it only aborted, with no id check? Select all that apply.
Caching: never ask twice
Watch how people really type. They type "laptop st", see nothing they like, press Backspace three times, and type "laptop ba". Without a cache, "laptop" is fetched again even though it was on screen two seconds ago.
People correct themselves all the time, and popular queries repeat across users all day. A cache turns those repeats into answers that show in the same frame as the keystroke.
There are four places a cache could live. They differ in speed, in who shares them, and in how long they last.
Decision Where should suggestion results be cached?
What we pick: A shared in-memory LRU with a 5 minute time limit, plus HTTP caching at the CDN for public queries. The memory cache gives instant hits for Backspace and repeats, and the CDN takes popular queries off the server. Persist only the user's own recent searches, in localStorage, because those are what the empty box shows on the next visit.
LRU means least recently used: when the cache is full, it drops the entry nobody has read for the longest time. TTL means time to live: each entry has an expiry time, and an expired entry counts as a miss. In JavaScript, a Map remembers insertion order, which makes an LRU about 15 lines. Reading an entry deletes and re-inserts it, so it moves to the newest end. When the map is too big, the first key is the oldest.
class LruCache<V> {
private map = new Map<string, { value: V; expires: number }>();
constructor(private max = 100, private ttlMs = 5 * 60_000) {}
get(key: string): V | undefined {
const entry = this.map.get(key);
if (!entry) return undefined;
this.map.delete(key);
if (entry.expires < Date.now()) return undefined;
this.map.set(key, entry); // now the most recently used
return entry.value;
}
set(key: string, value: V) {
this.map.delete(key);
this.map.set(key, { value, expires: Date.now() + this.ttlMs });
const oldest = this.map.keys().next().value;
if (this.map.size > this.max && oldest !== undefined) this.map.delete(oldest);
}
}
Reusing a prefix's results
Every match for "lapt" is also a match for "lap". So if we already have every match for "lap", we can filter them for "lapt" in the browser and skip the request.
The catch is the word "every". The server returns the top 8, not all 40,000 matches, and the best 8 for "lapt" may be matches 9 to 20 for "lap". Filtering the top 8 would hide them.
So the server tells us: each response carries complete: true when it returned every match, which happens when there are fewer matches than the limit. Long, specific queries are often complete: "laptop stand alu" may have only 3 matches. For those, every longer query can be answered locally. When the prefix isn't complete, its filtered results are still useful as a placeholder: show them at once, slightly dimmed, and replace them when the real answer arrives.
Store items once, and lists of ids per query
The same product shows up in the answers for "lap", "lapt", "laptop", and "laptop stand". Storing the full item in each entry wastes memory and lets copies disagree: one entry shows the old price.
Instead, normalize. Keep one map of items by id, and let each query entry hold only the ranked list of ids. 100 queries of 8 results each are 800 rows but often only 250 different items. When an item changes, every list that mentions it shows the new version.
What to show before the user types
An empty box on focus is a chance, not a blank. Show the user's recent searches, up to about 5, from localStorage, and a few popular or trending items from an endpoint that the CDN caches for 5 minutes. Fetch the popular items when the box gets focus or when the pointer moves over it, so they're usually ready before the first key. Recent searches are personal: key them by account, cap them at 10, let the user remove one, and clear them on logout.
Check your understanding · Exercise 5
The cache holds an entry for "lap" with 8 items and complete: false. The user types "lapt". What should the component do? Select all that apply.
Matching, ranking, and highlighting safely
Somebody has to decide which items match the text and in what order. For product search that's clearly the server: 2 million products don't fit in a browser. For the country field it's clearly the browser: 250 names are about 6 KB, and a round trip per keystroke would be silly. The people picker sits in between, and it's the interesting case.
Decision Where should matching and ranking happen?
What we pick: Let the size and privacy of the data decide, through the same source prop. A small public list is passed as an array and filtered in the browser. A large or permission-filtered set uses a server function. The people picker uses the hybrid: local matches first, server matches appended below them, so nothing the user is looking at moves.
Ranking local matches
"Contains the text" is a poor ranking on its own. For the query "ma", "Guatemala" contains it, but the user almost certainly wants "Madagascar" or "Malaysia". Rank by match quality in tiers, then break ties inside a tier by popularity, then alphabetically with Intl.Collator, so the order is right for the user's language.
| Tier | Rule | Example for "ma" |
|---|---|---|
| 1. Exact | The whole label equals the query. | (none) |
| 2. Starts with | The label starts with the query. | Madagascar, Malaysia, Mali |
| 3. A word starts with | Any word in the label starts with the query. | Isle of Man, San Marino |
| 4. Contains | The query appears anywhere. | Guatemala, Panama |
| 5. Fuzzy (optional) | Close spelling, such as one typo. | (too short to apply; "mlata" would find Malta) |
Precompute what you can. Folding each label to lowercase without accents on every keystroke costs time for 5,000 items. Fold them once when the list arrives and keep the folded copy next to the original. Then each keystroke is a cheap pass over prepared strings, about 1 to 2 ms for 5,000 items on a phone.
Highlighting the match without innerHTML
Highlighting the matched part, such as bold "Lap" in "Laptop stand", helps people scan. Many search APIs used to return it as HTML: "label": "<b>Lap</b>top stand". The tempting code is dangerouslySetInnerHTML or innerHTML.
That's an XSS hole, cross-site scripting: labels often come from users or sellers, and a seller can name a product <img src=x onerror=stealCookies()>. If that string is set as HTML, the browser runs it for everyone who types "img".
Keep labels as text from end to end. The server returns the label as plain text plus match ranges, such as matches: [[0, 3]], meaning from character 0 up to, but not including, character 3: "Lap". For local lists, the ranking code produces the ranges itself.
The component splits the label into segments and renders the matched ones inside a mark element. Because React inserts strings as text nodes, a label containing a tag is shown as harmless characters.
type Range = readonly [start: number, end: number];
function Highlighted({ label, ranges }: { label: string; ranges: readonly Range[] }) {
const parts: ReactNode[] = [];
let at = 0;
for (const [start, end] of ranges) {
if (start > at) parts.push(label.slice(at, start));
parts.push(<mark key={start}>{label.slice(start, end)}</mark>);
at = end;
}
if (at < label.length) parts.push(label.slice(at));
return <>{parts}</>;
}
<img onerror=...> is shown as those exact characters instead of running.Two details are easy to miss. First, the ranges must point into the original label, not the folded copy used for matching. Normalizing "Café" to the NFD form turns "é" into two characters, "e" plus an accent mark, so positions shift. Keep a map from folded positions back to original ones.
Second, decide what to highlight and stay consistent. Product search usually highlights the typed part. Google bolds the completion instead, the part the user hasn't typed yet, because that's what they're scanning for. Either works; mixing them in one product doesn't.
Check your understanding · Exercise 6
A product API returns "label": "<b>Lap</b>top stand". Which ways of showing it are safe? Select all that apply.
The combobox pattern: roles, focus, and announcements
A sighted mouse user sees the list, moves to an option, and clicks. A screen reader user hears only what the markup says. If the list is a div of divs, they hear the text box and nothing else: no list opened, no options, no idea that pressing Down does anything.
The WAI-ARIA Authoring Practices Guide, the APG, defines a pattern for exactly this widget, called the combobox. A combobox is a text box with a popup that helps set its value.
The hard question is where keyboard focus lives while the user moves through options. Focus is the element that receives key presses. Most widgets move focus to whatever is active. Here that breaks typing.
Decision Where should keyboard focus be while the user arrows through the suggestions?
What we pick: Keep DOM focus in the input and move a virtual focus with aria-activedescendant. It's the only option that keeps typing, the phone keyboard, and rich rows all working. Native datalist is a fine choice for a simple internal form with text-only options.
aria-controls ties it to the list, and aria-activedescendant names the highlighted option by id, so the screen reader reads that option as if it had focus.<label for="q">Search products</label>
<input id="q" type="text" role="combobox"
aria-autocomplete="list" aria-expanded="true"
aria-controls="q-list" aria-activedescendant="q-opt-p2"
autocomplete="off" autocapitalize="none" spellcheck="false" />
<ul id="q-list" role="listbox" aria-label="Suggestions">
<li id="q-opt-p1" role="option" aria-selected="false">Laptop stand</li>
<li id="q-opt-p2" role="option" aria-selected="true">Laptop sleeve, 14 inch</li>
<li id="q-opt-p3" role="option" aria-selected="false">Laptop bag</li>
</ul>
<div role="status" class="sr-only">3 suggestions</div>
role="combobox"on the input tells assistive technology that this box has a popup. Since ARIA 1.2 the role goes on the input itself; older ARIA 1.1 examples wrap the input in adivwith the role, and that structure is now outdated. A combobox's popup is a listbox by default, soaria-haspopupisn't needed.aria-expandedsays whether the popup is open. Update it every time; a staletruemakes screen readers describe a list that isn't there.aria-controlspoints at the listbox's id, so the two are linked even if the popup lives elsewhere in the DOM.aria-autocomplete="list"says suggestions appear in a list. Use"both"only if you also complete the text inline, like the grey ghost text some search boxes show.aria-activedescendantnames the highlighted option's id, or is removed when nothing is highlighted.- Each option has
role="option"and a stable, unique id built from the item's key, such asq-opt-p2. The highlighted one hasaria-selected="true". autocomplete="off"stops the browser's own form history from opening a second popup on top of ours.
Announcing result counts
When results change, a sighted user sees the list update. A screen reader user hears nothing unless we say it. Add a visually hidden status region, role="status", which screen readers read politely when its text changes, without interrupting. Put messages such as "8 suggestions", "No suggestions for laptpo", or "Couldn't load suggestions" in it.
Restraint matters. If the region changes on every keystroke, the user hears "8 suggestions, 8 suggestions, 7 suggestions" over their own typing. Update it only when the results settle, about 500 ms after the last change, and only when the message is different from the last one.
Keep each option's accessible text complete on its own, such as "Laptop sleeve, 14 inch, £24.99", because that's all the user hears when it's highlighted. Group headings like "Recent" and "Products" go in elements with role="group" and aria-labelledby, so screen readers that support groups can read the name when the highlight enters one.
The full keyboard model
Keyboard users expect the same keys to behave the same way in every combobox. The table follows the APG pattern for a list autocomplete. The one rule underneath all of it: arrow keys move the highlight, every other key edits text.
| Key | Popup closed | Popup open |
|---|---|---|
| Down Arrow | Opens it and highlights the first option. | Highlights the next option. On the last one, the APG allows staying put or going back to the text, and its own example wraps to the first. Pick one rule for the whole product. |
| Up Arrow | Opens it and highlights the last option. | Highlights the previous option. Above the first, the highlight goes back to the text. |
| Alt + Down Arrow | Opens it without highlighting anything. | Nothing. |
| Alt + Up Arrow | Nothing. | Closes the popup and keeps the typed text. |
| Enter | Submits the typed text, in free text mode. | Picks the highlighted option and closes. With nothing highlighted, submits the typed text. |
| Escape | Clears the text, if the product allows it. | Closes the popup and keeps the typed text. |
| Tab | Moves focus to the next control. | Closes the popup and moves on. It doesn't pick anything. |
| Left, Right, Home, End | Move the caret in the text. | Move the caret and clear the highlight. |
| Page Up, Page Down | Nothing. | Optional: jump 10 options in long lists. |
| Any character | Edits the text and opens the popup once the minimum length is reached. | Edits the text; the highlight resets. |
Mouse and touch need two small rules. Moving the pointer over an option highlights it, so mouse and keyboard share one highlight. Clicking an option must not blur the input first: call preventDefault() on the option's mousedown, or the input's blur handler closes the popup before the click lands.
Clicking outside the input and the popup closes it. The lab below is a real combobox: arrow through it and watch the attributes and what a screen reader would say.
Check your understanding · Exercise 7
A screen reader user arrows down through the suggestions, but hears nothing. Which bugs could cause it? Select all that apply.
Check your understanding · Exercise 8
The popup is open, nothing is highlighted, and the user has typed "usb c hub" in a free text product search. Which behaviors are right? Select all that apply.
Positioning the popup: edges, layers, and phone keyboards
The naive popup is a position: absolute element right after the input, at top: 100%. It works on a test page. Then the search box goes into a header with overflow: hidden, and the popup is clipped to nothing. In a modal, it's cut off at the modal's edge.
Near the bottom of the screen it opens below the fold. On a phone, the on-screen keyboard covers it entirely. Each of these is a different problem.
Decision How should the popup escape its containers and stay on screen?
What we pick: The top layer with flip-and-size placement on larger screens, and a full-screen sheet on phones for the main site search. Smaller pickers inside forms keep the anchored popup on phones too, using the visual viewport to stay above the keyboard. The Popover API is Baseline across current browsers, so use it, with a portal as the fallback.
The placement rule fits in a few lines. Measure the space below the input to the bottom of the visible area, and the space above it. If there's less than about 200 px below and more above, open above. Set max-height to the smaller of 320 px and the chosen space minus 8 px, so the popup scrolls internally instead of running off screen.
Make it at least as wide as the input. Then keep the highlighted option visible inside the popup with scrollIntoView({ block: "nearest" }) after each arrow key.
Phones and on-screen keyboards
On iOS Safari, and on Android Chrome by default, the on-screen keyboard shrinks the visual viewport, the part of the page the user can see, but not the layout viewport that position: fixed and 100vh are based on. So window.innerHeight says there's lots of room below the input when the keyboard covers half of it. Read window.visualViewport.height and offsetTop instead, and recompute when visualViewport fires resize or scroll. Scroll the input near the top of the visible area when it gets focus, so the results get the space between the box and the keyboard.
Updating on every scroll event can itself cost frames. Listen with passive: true, batch the work into one requestAnimationFrame callback, and do all the reads before any writes, so the browser never has to recompute layout in the middle. A ResizeObserver on the input catches width changes that don't come from the window, such as a sidebar opening.
Check your understanding · Exercise 9
The autocomplete is in a header with overflow: hidden, and the popup is clipped. Which fixes work? Select all that apply.
Long result lists: cap, page, or virtualize
A search autocomplete should show few results: 8 to 10 is what Google, Amazon, and most products settle on. More options are slower to scan, slower to render, and further to arrow through. Cap the list on the server with limit, and send people to the full results page with Enter for everything else.
Pickers are different. A time zone field has about 400 options, and a select-only people picker might let you scroll the whole team of 2,000 before typing. Render all 2,000 rows, at about 20 DOM nodes each, and every keystroke that changes the list rebuilds 40,000 nodes. On a mid-range phone that's well over 100 ms, which fails the 50 ms keystroke budget and makes typing stutter.
Virtualization renders only the rows that are visible, plus a few extra above and below, and stands in for the rest with empty space of the right height. A 320 px popup with 40 px rows shows 8 rows, so about 14 rows exist in the DOM whether the list has 50 items or 50,000. Fixed row heights keep the math simple: row i sits at i * 40 px.
aria-activedescendant at it. aria-setsize and aria-posinset let the screen reader say 44 of 2,000.Virtualization interacts with the combobox pattern in three ways. First, aria-activedescendant must name an element that exists, so when the highlight moves to row 400, scroll the window there before setting the attribute. Second, a screen reader can only count the options that exist, so give each rendered option aria-setsize with the full count and aria-posinset with its position, and the user hears "44 of 2,000". Third, the browser's find-in-page can't find rows that aren't rendered, which is fine inside a popup.
Check your understanding · Exercise 10
A time zone picker has 420 options in a virtualized popup. Which details are needed? Select all that apply.
Loading, empty, error, and offline states
The happy path is one state: results. Real use has seven, counting closed, and each needs to look right and sound right. The naive component has a boolean isLoading and an array. It flashes a spinner for 80 ms on every pause, shows an empty popup when nothing matches, and either swallows errors or replaces the list with a red message that steals the user's attention while they type.
Model the states explicitly as a small state machine: a set of named states and the events that move between them. Then each state has one look and one announcement, and impossible mixes such as "loading and error at once" can't happen.
| State | What the user sees | What a screen reader hears |
|---|---|---|
| Closed | Just the box. | The label and the typed text. |
| Idle (open, no query) | Recent searches and popular items. | "5 recent searches" once, when it opens. |
| Loading | The previous results, slightly dimmed. A small spinner only after 300 ms. | Nothing at first; "Loading" only if it takes over a second. |
| Results | Up to 10 options. | "8 suggestions", once the list settles. |
| Empty | "No results for laptpo", plus a hint such as checking the spelling. Not an option, so the arrows skip it. | "No suggestions for laptpo". |
| Error | "Couldn't load suggestions." Typing keeps working, and Enter still searches. | "Couldn't load suggestions", once. |
| Offline | "You're offline" with recent searches and any cached matches. | "Offline, showing recent searches". |
Loading without flicker
Most answers arrive in under 300 ms, so a spinner that appears at once mostly flickers. Keep the previous results on screen while the next ones load, mark the listbox aria-busy="true", and dim it a little. Show a spinner only if the request is still running after 300 ms.
When the new results arrive, swap them in one render. This is the same idea as React's useDeferredValue: show slightly old content instead of nothing.
Errors and retries
Suggestions are a help, not the task. When they fail, the user can still type and press Enter to search, so the error must never block that. Retry once, quietly, after about 1 second for network errors and 5xx responses. If that fails, show a short message in the popup, outside the listbox so the arrows don't land on it, and announce it once.
Typing more naturally retries. For 429 Too Many Requests, respect the Retry-After header and widen the debounce for a while. A 400 means our query was bad, so don't retry it at all.
Offline is a state of its own, not an error: check navigator.onLine and listen for the offline and online events. While offline, skip the network entirely and answer from the cache and recent searches. When the online event fires and the box still has focus, run the current query again. Note that navigator.onLine being true only means there's a network interface, not that the API is reachable, so a failed request on a flaky train is still handled by the error path.
Check your understanding · Exercise 11
Which state designs are right for this autocomplete? Select all that apply.
International text: input methods, accents, and right to left
A Japanese user typing 東京, Tokyo, doesn't press keys for those two characters. They type "toukyou" in Latin letters, and an input method editor, an IME, shows とうきょう with an underline. Pressing Space converts it to candidates such as 東京, and Enter confirms one.
Chinese, Korean, and many other languages work the same way. Until the user confirms, the text is a composition: a draft the IME owns.
The naive component breaks in two ways. It fetches suggestions for every draft, such as "t", "とう", and "とうきょ", which wastes requests and makes the list jump. Worse, it handles the Enter that confirms the IME candidate as "pick the highlighted suggestion", so the user's word is replaced by something they never chose. For hundreds of millions of users, this isn't an edge case: it's the main path.
Browsers report compositions with events. compositionstart fires when a draft begins, compositionupdate as it changes, and compositionend when it's confirmed or cancelled. Every input and keydown event during a draft has isComposing set to true. So: don't schedule fetches while composing, fetch once on compositionend with the confirmed text, and ignore every key while composing.
Safari fires compositionend before the keydown of the confirming Enter, so that keydown has isComposing set to false. Its keyCode is 229, the value browsers use for keys the IME handled, so check that too.
const composing = useRef(false);
<input
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={(event) => {
composing.current = false;
schedule(event.currentTarget.value); // the confirmed text
}}
onChange={(event) => {
setText(event.target.value); // always show what the IME shows
if (!composing.current) schedule(event.target.value);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
handleListKeys(event); // arrows, Enter, Escape
}}
/>
Some products do show suggestions for the draft, because Japanese search engines can match kana. If you do, keep the rule about keys: while composing, arrows and Enter belong to the IME's own candidate window, never to our list.
Accents, case, and what counts as a match
A user in a hurry types "zurich" and expects "Zürich". Fold both sides before comparing: text.normalize("NFD").replace(/\p{M}/gu, "") splits each accented letter into a base letter plus a combining mark, then drops the marks. Lowercase with toLocaleLowerCase(locale), not toLowerCase(), because in Turkish the capital I lowercases to a dotless ı. For sorting and equality, new Intl.Collator(locale, { sensitivity: "base" }) treats "a", "á", and "A" as equal.
Folding isn't right for every language. In Swedish, å, ä, and ö are separate letters at the end of the alphabet, and a Swede typing "a" may not want "Åre" first. That's an argument for doing matching on the server, with language-aware analyzers, for the big data sets, and keeping browser folding for small lists.
Chinese and Japanese have no spaces between words, so the "a word starts with the query" tier doesn't apply, and one character is already a meaningful query, which is why the minimum length there is 1. Send the user's locale with every request and include it in the cache key, because the same text can rank differently per language.
Right-to-left scripts
Arabic and Hebrew run right to left. Set dir="auto" on the input so the browser picks the direction from the first strong character typed, which also handles an English product name typed into an Arabic page. Align the popup to the input's inline start using logical CSS such as inset-inline-start instead of left, so it lines up on the right in a right-to-left page. Give each option dir="auto" too, since mixed results are common.
Up and Down still move the highlight; Left and Right still move the caret, and the browser handles which way that is. Highlight ranges are positions in the string, not on screen, so they stay correct however the text is drawn.
Finally, translate every message, including the ones only screen readers hear, and use plural rules. "1 suggestions" is a bug in English; Arabic has six plural forms. Intl.PluralRules or an ICU message format library picks the right form for each count.
Check your understanding · Exercise 12
A Japanese user types "toukyou", presses Space to convert it to 東京, then presses Enter to confirm. A suggestion is highlighted in our list. What should happen? Select all that apply.
Performance: fast typing and cheap rendering
Every keystroke is an interaction, and INP, Interaction to Next Paint, reports roughly the slowest one a user had on the page. Google counts an INP of 200 ms or less as good, measured at the 75th percentile. For typing, aim much lower: a letter that appears 100 ms after the key feels laggy even if INP is "good". Our budget is 50 ms from key press to the letter on screen, on a mid-range phone whose CPU is 4 to 6 times slower than a developer laptop.
Where does the time go? The key handler and the state update take 1 to 2 ms. Filtering and ranking a local list of 5,000 prepared items takes 1 to 2 ms.
Rendering 10 rich rows, each with an image, highlighted text, and a price, can take 30 to 50 ms on that phone, because React rebuilds them and the browser lays them out. So the list, not the input, is the threat to the budget.
Decision How do we keep typing responsive when rendering the list is slow?
What we pick: Defer the list's render by default. It targets the real cost, rendering, and costs one hook. Use a worker only when a local list is big enough that filtering itself shows up in profiles, which is rare under 20,000 items.
Making each render cheaper
- Re-render two rows, not ten, on Down Arrow. Memoize each option on its item and its own highlighted flag. Moving the highlight changes exactly two rows: the old one and the new one.
- Size images up front. Give each thumbnail a fixed
widthandheightso rows don't shift as photos load, and ask the server for a small size, such as 80 by 80, not the product page image. - Keep rows light. No tooltips, menus, or heavy components inside options. A row is a label, maybe a picture, and one line of detail.
- Measure in batches. Position the popup in a
requestAnimationFramecallback, never by reading layout inside the key handler right after a state change. - Prepare once. Fold and index local labels when the list arrives, not on each keystroke.
Loading cost: bytes and connections
The box is on every page, so its JavaScript is paid on every page, and the whole component should stay under about 10 KB compressed. Server-render the input itself, so it's visible and usable before any script runs. The popup, the cache, and the keyboard logic can load on first focus or pointer hover, since nobody needs suggestions before they reach the box. On focus, also warm the connection: a link rel="preconnect" to the suggestion API's origin saves the DNS, TCP, and TLS setup, often 100 to 300 ms on a phone, on the first request.
Measure in production, not just in the lab. Record INP for keystrokes in the box, the time from the last keystroke to suggestions shown, the cache hit rate, how many responses were dropped as stale, and how often suggestions are picked versus Enter on typed text. A falling pick rate after a change is often the first sign that ranking or timing got worse.
Check your understanding · Exercise 13
On a mid-range phone, typing in the product search feels laggy. Profiles show each keystroke takes 60 ms, 45 of it rendering 10 rows. Which changes help most? Select all that apply.
Putting it together: architecture, data, and the server API
Now that each decision is made, draw the whole thing. The rule behind every boundary: views only show state, one core owns the state, and everything about time and the network sits behind that core. When the list shows something wrong, there's one place to look.
| Part | Responsibility | Why it's separate |
|---|---|---|
Autocomplete | The default UI: input, popup, listbox, status region. Calls the caller's renderOption. | Most teams use only this. It's built entirely on the hook. |
useAutocomplete | The state machine: open, text, highlight, value, status. Keyboard handling and ARIA prop getters. | Teams with custom layouts reuse it without the default UI. |
| Scheduler | Normalizes the text, applies the minimum length, pauses during IME composition, debounces with a maximum wait. | Timing rules change often and are easiest to test alone, with fake timers. |
| Cache | The shared LRU with a time limit, prefix reuse, items stored once by id. | Shared across instances and remounts, so it can't live in one component. |
| Requests | Calls source, aborts old requests, checks request ids, shares a request already in flight, retries once. | All race logic in one place. |
Popup | Top layer or portal, flip and size, visual viewport, phone sheet. | Placement is the same problem as tooltips and menus, so it's shared with them. |
Listbox and Option | Rows, groups, virtualization, highlight segments. | Rendering cost lives here, so this is where memoization goes. |
LiveRegion | Settled, deduplicated status messages. | Announcements need their own timing, separate from rendering. |
source | The caller's function: REST, GraphQL, or a local array. | Data access belongs to each team. |
Follow one keystroke through it. The input reports the new text to the core, which updates inputValue at once so the letter paints. The core asks the scheduler whether this text is worth asking about. The scheduler normalizes it and checks the cache, which can answer in the same frame.
On a miss it waits for a pause, then hands the query to the requests part, which aborts the previous request, calls source, and checks the id when the answer comes back. The core moves to the results state, the listbox renders from a deferred copy, and the live region announces the count once the list settles.
Data model: what lives where
Everything here lives in memory except two things. Recent searches are in localStorage, per account. The chosen value lives wherever the caller keeps it: form state for the people picker, the URL's ?q= for product search. None of the cache needs to survive a reload.
type Status = "closed" | "idle" | "loading" | "results" | "empty" | "error" | "offline";
type AutocompleteState<T> = {
status: Status;
inputValue: string; // exactly what is in the box
query: string; // the normalized query the items belong to
items: readonly T[];
highlightedIndex: number; // -1 when nothing is highlighted
value: T | null; // the picked item
composing: boolean; // an IME draft is in progress
};
type CacheEntry = {
ids: readonly string[]; // ranked item ids for one query
complete: boolean; // every match is here, so longer queries can filter locally
};
// Shared by every instance; keys look like "products:en-GB:lapt".
const queries = new LruCache<CacheEntry>(100, 5 * 60_000);
const items = new Map<string, Suggestion>();
Keep inputValue and query apart. inputValue is raw and changes on every key, including during IME drafts. query is normalized and only changes when the scheduler decides a search is worth running. The items always belong to query, which is how the component knows whether what it shows matches the box.
The server API
Use a plain GET, so the browser and the CDN can cache it. Return only what a row needs, about 2 KB for 8 results, not full product records. Echo the query back, return match ranges instead of HTML, and say whether the result is complete.
GET /api/suggest?q=lapt&limit=8&locale=en-GB
HTTP/2 200
Content-Type: application/json
Cache-Control: public, max-age=60
{
"query": "lapt",
"complete": false,
"items": [
{ "id": "p_1842", "type": "product", "label": "Laptop stand, aluminium",
"matches": [[0, 4]], "image": "https://img.example.com/1842-80.webp",
"price": "£24.99" },
{ "id": "q_77", "type": "query", "label": "laptop bag", "matches": [[0, 4]] }
]
}
limitcaps results; the server never sends more than 10.localechanges matching and ranking, so it's part of the URL and the cache key.- Personalized results, such as people the user works with, use
Cache-Control: privateso the CDN never shares them. - Errors use one JSON shape, such as
{ "error": { "code": "rate_limited", "retryAfter": 5 } }, with429and aRetry-Afterheader for rate limits. - The
typefield lets one list mix queries, products, and categories, each rendered differently byrenderOption.
Check your understanding · Exercise 14
Which statements about this architecture are true? Select all that apply.
Running it in the interview
An autocomplete question tests whether you can see past the obvious. Everyone draws a box and a list. Strong candidates spend their time on the parts that break: timing and races, the combobox pattern, and the component API. Here's a plan for 45 minutes.
- Minutes 0 to 5: requirements. Ask about the data source, what a row looks like, free text versus select-only, devices, and languages. Write the four numbers in a corner.
- Minutes 5 to 12: API and architecture. Sketch the props:
source,renderOption, the two controlled values, the events. Draw the core with the scheduler, cache, and requests behind it. - Minutes 12 to 25: the network. Debounce with numbers, the naive race and its fix with abort plus ids, the LRU cache with prefix reuse. This is where you show depth.
- Minutes 25 to 35: accessibility. The combobox markup,
aria-activedescendantand why focus stays in the input, the keyboard table, the status region. - Minutes 35 to 42: whatever the interviewer cares about. Positioning, IME, performance, or states. Ask which one they'd like.
- Minutes 42 to 45: wrap up. Summarize the decisions and name what you'd measure in production.
Say the failures out loud before the fixes. "If we fetch on every key, typing laptop sends six requests, and the last one to arrive could be for lapto." A concrete failure makes the fix obviously necessary, and it shows you've built one before. The same goes for accessibility: "if focus moved into the list, the phone keyboard would close" is a stronger argument than "the APG says so".
Think first
The interviewer asks: "How would you add inline completion, the grey text that finishes the word as you type?" What changes?
The top suggestion's remaining characters are shown after the caret, selected or styled as a hint, and Right Arrow or Tab accepts them. The ARIA changes to aria-autocomplete="both", because suggestions now appear both in the list and inline.
The tricky parts are the edge cases. Only complete when the suggestion starts with the exact typed text, or the inline part would rewrite what the user typed. Never complete during IME composition. Backspace must remove the completion, not the user's last letter. And the completion must update in the same frame as the keystroke, so it only uses cached or local results, never a pending request.
Think first
The interviewer asks: "How would you test this component?" What would you cover?
Unit tests for the scheduler and the requests part with fake timers and a fake source whose answers resolve in a chosen order: the lap and laptop race, abort on Escape, cache hits bumping the id, maxWait firing for a fast typist.
Integration tests that render the component and drive it with the keyboard through Testing Library, checking aria-expanded, aria-activedescendant, and the status text. Composition events can be dispatched in the test too. Then an end-to-end test in a real browser for placement near viewport edges, an axe scan, and a manual pass with each major screen reader.
Check your understanding · Exercise 15
You have 10 minutes left and haven't talked about accessibility at all. Which points give the most value in that time? Select all that apply.
Summary
- Split the two values: the text in the box and the picked item. Each can be controlled or uncontrolled, and every event says why it fired.
- Pass data in as a
source(query, { signal })function and rows asrenderOption, on top of a headless hook that owns timing, races, caching, keyboard, and ARIA. - Check the cache on every keystroke, and send network requests only after a 200 ms pause, with a 1,000 ms maximum wait and a minimum length.
- Abort old requests and check request ids. Abort saves bandwidth; the id check is what guarantees the list matches the box.
- Cache in a shared LRU with a time limit, store items once by id, and reuse a prefix's results when the server says they're complete.
- Keep labels as text with match ranges. Never set suggestion HTML with
innerHTML. - Keep focus in the input and move a virtual highlight with
aria-activedescendant; announce result counts once, through a polite status region. - Pause fetching and ignore keys during IME composition, fold accents carefully, and use logical CSS and
dir="auto"for right-to-left text. - Protect typing first: paint the letter, then render the list from a deferred value.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices Guide: Combobox pattern Roles, states, and the keyboard model, with working examples.
- MDN: AbortController How to cancel a fetch and what the rejection looks like.
- MDN: aria-activedescendant Virtual focus inside composite widgets.
- MDN: compositionstart event The events an input method editor fires.
- MDN: Popover API Putting a popup in the top layer without a portal.
- MDN: Visual Viewport API Measuring the visible area when the on-screen keyboard is open.
- web.dev: Interaction to Next Paint (INP) How every keystroke is measured.
- React: useDeferredValue Keeping input responsive while a slow list renders.
- OWASP: Cross Site Scripting Prevention Cheat Sheet Why suggestion labels must stay text.
Concepts behind it
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- In-memory cachesData your app keeps as live JavaScript objects in the tab's memory, keyed and bounded, so that going back, switching tabs, or opening the same thing twice is instant: the fastest cache you have, and the easiest one to let grow out of control.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.