Search Suggestions & Autocomplete
Debounced query, keyboard nav, recent searches, and result grouping.
UX PatternsIntermediate
Why
Users type slow, misspell, and don't know what's available. Suggestions as they type cut the query from 20 keystrokes to 3, surface content they didn't know existed, and correct spelling.
Nielsen Norman Group research shows users spend an average of 10-15 seconds on a page before leaving. Clear visual hierarchy and progressive disclosure improve task completion rates by 40-60%.
When
Any search with >1000 items or where users don't know exact terms: products, articles, people, commands. Skip for small fixed lists (a 10-item filter) — a dropdown is enough.
How
1. Debounce — wait 150–300ms after the last keystroke before querying; don't query per keystroke
2. Min length — require 2+ chars before querying; 1-char queries are noise
3. Cache — cache results per query; repeat queries are instant
4. Keyboard — ↑↓ navigate, Enter select, Esc close, Tab accept the highlighted suggestion
5. Sources — mix recent searches, popular queries, and live results; label each group
6. Highlight — bold the matched substring in each suggestion so users see why it matched
7. A11y — combobox role, aria-activedescendant for the highlighted option, live region for result count
8. Loading — show a spinner or skeleton after 200ms; don't flash it for fast queries
Example
function SearchBox() {
const [q, setQ] = useState("");
const [highlight, setHighlight] = useState(-1);
const { results, loading } = useSearch(debounce(q, 200)); // 200ms debounce
return (
<Combobox value={q} onChange={setQ}>
<ComboboxInput aria-label="Search" />
<ComboboxList>
{loading && <li>Loading…</li>}
{!loading && results.map((r, i) => (
<ComboboxOption
key={r.id}
value={r.title}
aria-selected={i === highlight}
>
<mark>{r.title.slice(0, q.length)}</mark>{r.title.slice(q.length)}
</ComboboxOption>
))}
</ComboboxList>
</Combobox>
);
}
// useSearch — debounced, cached, min-length
function useSearch(q: string) {
const cache = useRef<Map<string, Result[]>>(new Map());
const [results, setResults] = useState<Result[]>([]);
useEffect(() => {
if (q.length < 2) return setResults([]); // min length
if (cache.current.has(q)) return setResults(cache.current.get(q)!);
api.search(q).then((r) => { cache.current.set(q, r); setResults(r); });
}, [q]);
return { results, loading: q.length >= 2 && !cache.current.has(q) };
}
Common Problems
- ⚠Query per keystroke — 20 requests for 'hello', backend hammered, UI janky
- ⚠No keyboard navigation — mouse-only, power users can't tab through suggestions
- ⚠No highlight on match — users can't see why a suggestion appeared, trust is low
Best Practices
- ✓Debounce 200ms + min 2 chars — cuts requests 10x, no noise from single-char queries
- ✓Full keyboard support — ↑↓ navigate, Enter select, Esc close; power-user friendly
- ✓Highlight the matched substring — users see why it matched, builds trust in the results