Skip to main content
Back to Blog
Tutorials
4 min read
December 9, 2024

How to Build a Search Autocomplete Component in React

Build an accessible search autocomplete with debounced API calls, keyboard navigation, and highlighting in React.

Ryel Banfield

Founder & Lead Developer

Search autocomplete improves UX by showing suggestions as users type. Here is how to build one with proper debouncing, keyboard navigation, and accessibility.

The Hook

// hooks/use-debounce.ts
import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

The Search API

// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";

interface SearchResult {
  id: string;
  title: string;
  description: string;
  url: string;
  category: string;
}

const data: SearchResult[] = [
  { id: "1", title: "Web Design Services", description: "Custom website design for businesses", url: "/services/web-design", category: "Services" },
  { id: "2", title: "Mobile App Development", description: "iOS and Android applications", url: "/services/mobile-apps", category: "Services" },
  { id: "3", title: "About Us", description: "Learn about our team and mission", url: "/about", category: "Pages" },
  // ... more entries
];

export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get("q")?.toLowerCase().trim();
  if (!query || query.length < 2) {
    return NextResponse.json({ results: [] });
  }

  const results = data
    .filter(
      (item) =>
        item.title.toLowerCase().includes(query) ||
        item.description.toLowerCase().includes(query)
    )
    .slice(0, 8);

  return NextResponse.json({ results });
}

The Autocomplete Component

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { useRouter } from "next/navigation";

interface SearchResult {
  id: string;
  title: string;
  description: string;
  url: string;
  category: string;
}

function highlightMatch(text: string, query: string) {
  if (!query) return text;
  const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
  const parts = text.split(regex);

  return parts.map((part, i) =>
    regex.test(part) ? (
      <mark key={i} className="bg-yellow-200 dark:bg-yellow-800 rounded-sm px-0.5">
        {part}
      </mark>
    ) : (
      part
    )
  );
}

export function SearchAutocomplete() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [loading, setLoading] = useState(false);

  const debouncedQuery = useDebounce(query, 300);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const router = useRouter();
  const listboxId = "search-listbox";

  // Fetch results
  useEffect(() => {
    if (debouncedQuery.length < 2) {
      setResults([]);
      setIsOpen(false);
      return;
    }

    const controller = new AbortController();

    async function fetchResults() {
      setLoading(true);
      try {
        const res = await fetch(
          `/api/search?q=${encodeURIComponent(debouncedQuery)}`,
          { signal: controller.signal }
        );
        const data = await res.json();
        setResults(data.results);
        setIsOpen(data.results.length > 0);
        setActiveIndex(-1);
      } catch (e) {
        if ((e as Error).name !== "AbortError") {
          setResults([]);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchResults();
    return () => controller.abort();
  }, [debouncedQuery]);

  const selectResult = useCallback(
    (result: SearchResult) => {
      setQuery(result.title);
      setIsOpen(false);
      router.push(result.url);
    },
    [router]
  );

  // Keyboard navigation
  function handleKeyDown(e: React.KeyboardEvent) {
    if (!isOpen) {
      if (e.key === "ArrowDown" && results.length > 0) {
        setIsOpen(true);
        setActiveIndex(0);
        e.preventDefault();
      }
      return;
    }

    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((prev) => (prev + 1) % results.length);
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((prev) => (prev - 1 + results.length) % results.length);
        break;
      case "Enter":
        e.preventDefault();
        if (activeIndex >= 0 && results[activeIndex]) {
          selectResult(results[activeIndex]);
        }
        break;
      case "Escape":
        setIsOpen(false);
        setActiveIndex(-1);
        break;
    }
  }

  // Scroll active item into view
  useEffect(() => {
    if (activeIndex >= 0 && listRef.current) {
      const activeElement = listRef.current.children[activeIndex] as HTMLElement;
      activeElement?.scrollIntoView({ block: "nearest" });
    }
  }, [activeIndex]);

  // Close on click outside
  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (
        !inputRef.current?.contains(e.target as Node) &&
        !listRef.current?.contains(e.target as Node)
      ) {
        setIsOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, []);

  // Group results by category
  const grouped = results.reduce<Record<string, SearchResult[]>>((acc, result) => {
    if (!acc[result.category]) acc[result.category] = [];
    acc[result.category].push(result);
    return acc;
  }, {});

  return (
    <div className="relative w-full max-w-md">
      <div className="relative">
        <svg
          className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
          fill="none"
          viewBox="0 0 24 24"
          stroke="currentColor"
          aria-hidden="true"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
          />
        </svg>

        <input
          ref={inputRef}
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={handleKeyDown}
          onFocus={() => results.length > 0 && setIsOpen(true)}
          placeholder="Search..."
          role="combobox"
          aria-expanded={isOpen}
          aria-controls={listboxId}
          aria-activedescendant={
            activeIndex >= 0 ? `search-option-${results[activeIndex]?.id}` : undefined
          }
          aria-autocomplete="list"
          className="w-full pl-10 pr-10 py-2 border rounded-md bg-background"
        />

        {loading && (
          <div className="absolute right-3 top-1/2 -translate-y-1/2">
            <div className="h-4 w-4 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
          </div>
        )}
      </div>

      {isOpen && (
        <ul
          ref={listRef}
          id={listboxId}
          role="listbox"
          className="absolute top-full mt-1 w-full bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto z-50"
        >
          {Object.entries(grouped).map(([category, items]) => (
            <li key={category} role="presentation">
              <div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider bg-muted/50">
                {category}
              </div>
              <ul role="group" aria-label={category}>
                {items.map((result) => {
                  const index = results.indexOf(result);
                  return (
                    <li
                      key={result.id}
                      id={`search-option-${result.id}`}
                      role="option"
                      aria-selected={index === activeIndex}
                      onClick={() => selectResult(result)}
                      onMouseEnter={() => setActiveIndex(index)}
                      className={`px-3 py-2 cursor-pointer ${
                        index === activeIndex ? "bg-primary/10" : "hover:bg-muted"
                      }`}
                    >
                      <div className="font-medium text-sm">
                        {highlightMatch(result.title, debouncedQuery)}
                      </div>
                      <div className="text-xs text-muted-foreground">
                        {highlightMatch(result.description, debouncedQuery)}
                      </div>
                    </li>
                  );
                })}
              </ul>
            </li>
          ))}
        </ul>
      )}

      {/* Screen reader status */}
      <div role="status" aria-live="polite" className="sr-only">
        {loading
          ? "Searching..."
          : results.length > 0
            ? `${results.length} results found`
            : query.length >= 2
              ? "No results found"
              : ""}
      </div>
    </div>
  );
}

Usage

// In any page or header
import { SearchAutocomplete } from "@/components/SearchAutocomplete";

export default function Header() {
  return (
    <header className="flex items-center justify-between px-4 py-3 border-b">
      <span className="font-bold">My Site</span>
      <SearchAutocomplete />
    </header>
  );
}

Key Features

  • Debounced API calls to avoid hammering the server on every keystroke
  • Keyboard navigation with arrow keys, Enter to select, Escape to close
  • ARIA combobox pattern for screen reader support
  • Result highlighting to show matching text
  • Grouped results by category
  • AbortController to cancel stale requests
  • Live region to announce result counts to screen readers

Need Search for Your Website?

We build fast, accessible search experiences with autocomplete, filtering, and faceted search. Contact us to add search to your site.

searchautocompletecomboboxdebounceReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles