Search With Results Panel in React: Debounce, Highlight, Keyboard Nav
Build a fully-featured search results panel in React with debounced input, match highlighting, and full keyboard navigation — no library required.
Why Building This From Scratch Is Worth It
You've reached for a headless UI library, grabbed a combobox, wrestled with its ARIA props for two hours, and ended up with a component you don't fully control. It happens. And honestly, for a search-with-results panel — the kind where you type, get a dropdown of matches, navigate with arrow keys, and see your query highlighted inside each result — you don't need a dependency at all. The whole thing fits in about 150 lines.
What you're building here is sometimes called a combobox, sometimes an autocomplete, sometimes just a 'command palette lite.' The name doesn't matter. What matters is the three hard parts: debouncing the input so you're not firing a network call on every keystroke, highlighting the matched substring inside each result, and wiring up keyboard navigation so the panel feels native. That last one is where most DIY attempts fall apart.
This guide assumes React 18+ and TypeScript. No extra packages beyond what you already have. Worth noting: if you want a ready-made version with glassmorphic styling, the Empire UI component library has a search component you can drop in and theme — but knowing how to build it yourself means you can adapt whatever you find.
Quick aside: the techniques here — useRef for the list container, aria-activedescendant for screen reader state, onKeyDown on the input — apply to basically any keyboard-navigable list in React, not just search. So you're getting a reusable mental model, not just a copy-paste widget.
Debouncing the Input: Do It With useRef, Not useState
The instinct is to add a debounce wrapper from lodash or write one with setTimeout inside a useEffect. The useEffect version technically works, but you end up with a dependency array that fights you. There's a cleaner approach using useRef to hold the timer ID.
import { useRef, useState, useCallback } from 'react';
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
return useCallback(
(...args: Parameters<T>) => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => fn(...args), delay);
},
[fn, delay]
);
}You call it like this in your search component: const debouncedSearch = useDebounce(fetchResults, 300). The 300ms delay is the sweet spot I've landed on — 200ms feels jittery on slow connections, 400ms starts feeling sluggish. That said, if your results come from a local array (no network), you can drop all the way to 100ms or skip debouncing entirely.
One thing people miss: you should also call debouncedSearch with an empty string when the input is cleared, so the panel closes cleanly. Don't wait for the debounce — clear results immediately on empty input. Users expect the panel to disappear the moment they backspace to nothing.
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setQuery(val);
if (!val.trim()) {
setResults([]);
return;
}
debouncedSearch(val);
}Highlighting the Matched Substring Without dangerouslySetInnerHTML
The naive approach is string interpolation with dangerouslySetInnerHTML. Don't. You lose XSS safety and React's diffing benefits in one move. The right approach is splitting the result label into three chunks — before the match, the match itself, after the match — and rendering them as separate spans.
function HighlightMatch({
text,
query,
}: {
text: string;
query: string;
}) {
if (!query) return <span>{text}</span>;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return <span>{text}</span>;
const before = text.slice(0, idx);
const match = text.slice(idx, idx + query.length);
const after = text.slice(idx + query.length);
return (
<span>
{before}
<mark className="bg-yellow-200 dark:bg-yellow-800 rounded-sm px-0.5">
{match}
</mark>
{after}
</span>
);
}In practice, this covers 90% of use cases. If you need to highlight multiple occurrences (say, the query appears twice in the label), you'd turn this into a recursive split or a regex matchAll loop. For most search UIs — product names, user names, article titles — a single first-match highlight is exactly what users expect anyway.
Worth noting: the <mark> element is semantically correct here. Screen readers announce it as highlighted text. Add a title or aria-label if your design system strips the default mark styling, so you're not losing that semantic signal for visually impaired users.
Keyboard Navigation: Arrow Keys, Enter, Escape
This is the part that makes or breaks the component. Users expect ArrowDown to move selection down, ArrowUp to move it up, Enter to activate the highlighted item, and Escape to close the panel. If any of those don't work, the whole thing feels broken — no matter how nice the highlight animation is.
The state you need is a single activeIndex number. -1 means nothing is selected (the input retains focus visually). Anything >= 0 means that result row is highlighted. You manage this with onKeyDown on the input element.
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!results.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, -1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
onSelect(results[activeIndex]);
} else if (e.key === 'Escape') {
setResults([]);
setActiveIndex(-1);
}
}You also need to scroll the active item into view when navigating with the keyboard. The simplest way: in a useEffect watching activeIndex, call listRef.current?.children[activeIndex]?.scrollIntoView({ block: 'nearest' }). The block: 'nearest' option is key — it only scrolls if the element is actually out of view. Without it, the list jumps around annoyingly.
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const item = listRef.current.children[activeIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex]);One more thing — reset activeIndex to -1 whenever the query changes. If the user types more characters and the results update, you don't want a stale index pointing at a different item.
Putting It Together: The Full Component
Here's the complete component wired up. It accepts a search async function, an onSelect callback, and an optional placeholder. The panel renders below the input, max-height 320px with overflow scroll, so it doesn't blow out the layout on long result sets.
import { useState, useRef, useCallback, useEffect } from 'react';
type Result = { id: string; label: string };
interface SearchWithResultsProps {
search: (query: string) => Promise<Result[]>;
onSelect: (result: Result) => void;
placeholder?: string;
}
export function SearchWithResults({
search,
onSelect,
placeholder = 'Search…',
}: SearchWithResultsProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const listRef = useRef<HTMLUListElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const runSearch = useCallback(
async (q: string) => {
setIsLoading(true);
try {
const data = await search(q);
setResults(data);
setActiveIndex(-1);
} finally {
setIsLoading(false);
}
},
[search]
);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setQuery(val);
if (!val.trim()) {
setResults([]);
setActiveIndex(-1);
return;
}
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => runSearch(val), 300);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!results.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, -1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
onSelect(results[activeIndex]);
setQuery(results[activeIndex].label);
setResults([]);
} else if (e.key === 'Escape') {
setResults([]);
setActiveIndex(-1);
}
}
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const item = listRef.current.children[activeIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex]);
const showPanel = results.length > 0 || isLoading;
return (
<div className="relative w-full max-w-md" role="combobox" aria-expanded={showPanel}>
<input
type="search"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-autocomplete="list"
aria-controls="search-results"
aria-activedescendant={
activeIndex >= 0 ? `result-${results[activeIndex]?.id}` : undefined
}
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showPanel && (
<ul
id="search-results"
role="listbox"
ref={listRef}
className="absolute z-50 mt-1 w-full max-h-80 overflow-y-auto rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-lg py-1"
>
{isLoading && (
<li className="px-4 py-2 text-sm text-zinc-400">Searching…</li>
)}
{results.map((r, i) => (
<li
key={r.id}
id={`result-${r.id}`}
role="option"
aria-selected={i === activeIndex}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => {
onSelect(r);
setQuery(r.label);
setResults([]);
}}
className={`px-4 py-2 text-sm cursor-pointer ${
i === activeIndex
? 'bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
: 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<HighlightMatch text={r.label} query={query} />
</li>
))}
</ul>
)}
</div>
);
}Look, that's a lot of code to read at once. The key choices to notice: role="combobox" on the wrapper, role="listbox" on the ul, role="option" on each li. The aria-activedescendant on the input pointing to the active item's id — that's how screen readers track focus without actually moving DOM focus out of the input. Don't skip those.
The onMouseEnter updating activeIndex is intentional. It means mouse and keyboard navigation stay in sync — if you hover an item, then press ArrowDown, you continue from where the mouse was. That interaction detail is one of the things that separates a polished component from one that feels slightly off.
Styling the Panel: Dark Mode, Z-Index, and Opening Animation
The z-index situation bites people constantly. Your search component is probably inside a nav or header, which might have its own stacking context (any element with position: sticky, transform, or filter creates one). If the panel keeps disappearing behind other elements, set z-50 on the list and check that no ancestor has a lower stacking context. This is an especially common problem in 2026 with sticky navs everywhere.
For the opening animation, a simple fade-in plus 4px translate-y looks professional without being distracting. In Tailwind you'd normally reach for a transition group, but the component as written mounts/unmounts the panel conditionally. The cleanest way without adding Framer Motion is a CSS keyframe:
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-panel {
animation: panel-in 120ms ease-out;
}Apply the search-panel class to the <ul> element. 120ms is fast enough that it doesn't feel heavy — any slower and it starts blocking the user. You can also do this entirely in Tailwind with the animate-in and fade-in utilities if you're on Tailwind v3.3+.
If you want glassmorphic styling for the panel — frosted background, subtle border — check out the glassmorphism generator to get the exact backdrop-filter and background values. Pair it with the box shadow generator to dial in the panel shadow without guessing.
Edge Cases Worth Handling Before You Ship
Clicking outside the panel should close it. Wire up a useEffect that adds a mousedown listener to document and compares event.target against a ref wrapping the whole component. Don't use onBlur on the input — it fires before the click handler on the list item, so selecting a result with a click closes the panel before onClick runs.
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setResults([]);
setActiveIndex(-1);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);Also handle the empty state explicitly. If the search returns zero results, show a 'No results for "query"' message rather than silently hiding the panel. Users don't know if the panel closed because there's nothing or because something broke. A visible empty state removes that ambiguity.
Finally, cancel in-flight requests if the query changes before they resolve. With fetch, you'd use an AbortController. Store the controller in a ref, abort on the next call. Without this, a slow request for 'ap' can resolve after a faster request for 'apple' and overwrite the correct results with stale ones. Genuinely annoying bug to track down after the fact.
const abortRef = useRef<AbortController | null>(null);
const runSearch = useCallback(async (q: string) => {
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
try {
const data = await search(q, abortRef.current.signal);
setResults(data);
} catch (err) {
if ((err as Error).name !== 'AbortError') throw err;
}
}, [search]);FAQ
Downshift and Radix UI Combobox handle a lot of edge cases (complex ARIA patterns, mobile quirks) and are worth it for large design systems. For a single search component with straightforward requirements, building it yourself is faster and gives you full control. The 150-line version above covers what most apps actually need.
Don't use onBlur on the input to close the panel — it fires before onClick on the list items. Instead, use a mousedown listener on document that checks whether the click target is inside your component container ref, as shown in the edge cases section.
Moving DOM focus to each result on ArrowDown would fire blur/focus events and potentially dismiss the panel or trigger other side effects. aria-activedescendant tells screen readers which item is 'active' while keeping real focus on the input, so you can keep typing without interruption.
300ms works well for network searches — it waits out normal typing speed without feeling laggy. For local array filtering with no network call, 100ms or even 0ms (no debounce) is fine. Avoid going above 400ms; at that point users notice the delay and it feels unresponsive.