DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building an Accessible Autocomplete in React

Building an Accessible Autocomplete in React

Building an Accessible Autocomplete in React

An accessible autocomplete is a great frontend project because it combines real UI state, keyboard interaction, async data fetching, and careful focus management. This guide walks through a production-style implementation with reusable code patterns, including debounced search, keyboard navigation, and screen-reader-friendly announcements.

What we’re building

We’ll build a search box that suggests matching items as the user types, lets them move through results with the keyboard, and announces updates to assistive technologies without stealing focus. Accessible autocomplete UIs need semantic HTML, proper labels, and deliberate focus handling so keyboard-only and screen-reader users can use them comfortably.

The core pieces are:

  • A controlled input.
  • A debounced query.
  • An async suggestion list.
  • Arrow-key navigation and selection.
  • An aria-live status region for announcements.

Component shape

A clean way to structure this is to separate the data logic from the presentational UI. Keep the query, loading state, results, and keyboard index in a custom hook, then pass those values into a small component tree. React’s useEffect is designed to synchronize components with external systems like async requests, and custom hooks are a good fit when you want to reuse that logic.

import { useEffect, useMemo, useRef, useState } from "react";

type Suggestion = {
  id: string;
  label: string;
};

function useDebouncedValue<T>(value: T, delay = 250) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = window.setTimeout(() => setDebounced(value), delay);
    return () => window.clearTimeout(id);
  }, [value, delay]);

  return debounced;
}
Enter fullscreen mode Exit fullscreen mode

This useDebouncedValue hook is intentionally small and reusable. Debouncing helps avoid firing a request on every keystroke, which makes the UI feel smoother and reduces wasted work.

Fetching suggestions

Next, create a hook that fetches data whenever the debounced query changes. Use an AbortController so a stale request does not overwrite the latest results, and reset keyboard focus when the query changes. React’s effect dependencies should include every reactive value you use, because effects re-run when those values change.

function useAutocomplete(query: string) {
  const debouncedQuery = useDebouncedValue(query, 250);
  const [items, setItems] = useState<Suggestion[]>([]);
  const [loading, setLoading] = useState(false);
  const [status, setStatus] = useState("Type to search.");
  const [activeIndex, setActiveIndex] = useState(-1);

  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setItems([]);
      setStatus("Type to search.");
      setActiveIndex(-1);
      return;
    }

    const controller = new AbortController();

    async function run() {
      setLoading(true);
      setStatus("Loading suggestions...");

      try {
        const res = await fetch(`/api/suggestions?q=${encodeURIComponent(debouncedQuery)}`, {
          signal: controller.signal,
        });
        const data: Suggestion[] = await res.json();

        setItems(data);
        setActiveIndex(data.length ? 0 : -1);
        setStatus(
          data.length
            ? `${data.length} suggestions available. Use up and down arrows to navigate.`
            : "No suggestions found."
        );
      } catch (err) {
        if ((err as Error).name !== "AbortError") {
          setStatus("Failed to load suggestions.");
        }
      } finally {
        setLoading(false);
      }
    }

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

  return { items, loading, status, activeIndex, setActiveIndex, setItems, setStatus };
}
Enter fullscreen mode Exit fullscreen mode

This pattern keeps async concerns in one place and makes the UI component easier to reason about. A separate loading message and result message also gives you a straightforward hook for accessibility feedback.

Keyboard interaction

Autocomplete must work without a mouse. The common behavior is ArrowDown, ArrowUp, Enter to select, and Escape to close the list, with focus staying on the input while the user navigates the options. MDN’s React accessibility guidance emphasizes focus management, and aria-live is useful for announcements because it informs users without moving focus.

function Autocomplete() {
  const [query, setQuery] = useState("");
  const { items, loading, status, activeIndex, setActiveIndex, setStatus } =
    useAutocomplete(query);

  const inputRef = useRef<HTMLInputElement | null>(null);

  function choose(item: Suggestion) {
    setQuery(item.label);
    setStatus(`Selected ${item.label}.`);
    setActiveIndex(-1);
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (!items.length) return;

    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((i) => Math.min(i + 1, items.length - 1));
    }

    if (e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((i) => Math.max(i - 1, 0));
    }

    if (e.key === "Enter" && activeIndex >= 0) {
      e.preventDefault();
      choose(items[activeIndex]);
    }

    if (e.key === "Escape") {
      setActiveIndex(-1);
      setStatus("Suggestions closed.");
    }
  }

  return (
    <div className="autocomplete">
      <label htmlFor="search">Search products</label>
      <input
        id="search"
        ref={inputRef}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onKeyDown={onKeyDown}
        aria-autocomplete="list"
        aria-controls="suggestions-list"
        aria-expanded={items.length > 0}
        aria-activedescendant={activeIndex >= 0 ? `suggestion-${items[activeIndex].id}` : undefined}
      />

      <div aria-live="polite" className="sr-only">
        {status}
      </div>

      {loading && <p>Loading…</p>}

      {items.length > 0 && (
        <ul id="suggestions-list" role="listbox">
          {items.map((item, index) => (
            <li
              key={item.id}
              id={`suggestion-${item.id}`}
              role="option"
              aria-selected={index === activeIndex}
              className={index === activeIndex ? "active" : ""}
              onMouseDown={() => choose(item)}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Using aria-activedescendant keeps focus on the input while indicating which option is active, which is a strong pattern for list-based widgets. The live region announces state changes, but it does not take focus itself.

Accessibility details

A lot of autocomplete bugs are really accessibility bugs in disguise. Use a real <label>, keep the input in the tab order, preserve the browser’s focus indicator, and make sure the list options have a clear selected state. Responsive layout also matters here because users may zoom the page or use small screens, so avoid designs that depend on fixed widths or awkward overflow.

Practical rules to follow:

  • Do not remove the focus outline unless you replace it with an equally visible style.
  • Keep the source order aligned with reading order, especially in responsive layouts.
  • Announce loading, empty, and error states in a live region.
  • Use semantic roles only where native HTML does not already provide the behavior you need.

Styling and layout

The visual design should make the active item obvious without relying only on color. A subtle background change, bold text, and a visible focus ring work well together. On small screens, keep the dropdown aligned with the input and allow the list to scroll instead of pushing the page around.

.autocomplete {
  position: relative;
  max-width: 32rem;
}

.autocomplete input {
  width: 100%;
  padding: 0.75rem 1rem;
}

.autocomplete ul {
  margin: 0.5rem 0 0;
  padding: 0;
  list-style: none;
  max-height: 16rem;
  overflow: auto;
  border: 1px solid #ddd;
  border-radius: 0.5rem;
}

.autocomplete li {
  padding: 0.75rem 1rem;
  cursor: pointer;
}

.autocomplete li.active {
  background: #eef4ff;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
}
Enter fullscreen mode Exit fullscreen mode

This keeps the dropdown usable even when there are many results, and it avoids cluttering the main page flow. The sr-only class gives you a screen-reader-friendly place for status updates.

Production patterns

Once the basic version works, there are a few upgrades that make it more robust. If the data set becomes large, consider virtualization for the dropdown list so you only render visible items, which improves performance for long lists. If the search endpoint is stable and the query is reusable across components, you can move the fetch logic into a dedicated data layer or cache library.

Good next steps are:

  1. Add caching for repeated queries.
  2. Add Escape to close the list and restore the idle state.
  3. Keep a separate empty state for “no results” versus “not loaded yet.”
  4. Add tests for keyboard movement and selection behavior.
  5. Profile the list if the number of suggestions grows.

Testing the behavior

Test this component like a user would use it. Verify that typing updates the list after the debounce delay, arrow keys move the active option, Enter selects the highlighted item, and screen-reader status messages change when results arrive. React’s accessibility guidance and aria-live documentation make it clear that the live region should announce updates without taking focus, which is exactly what you want to preserve keyboard flow.

A simple test checklist:

  • Typing “ap” eventually fetches matching suggestions.
  • ArrowDown highlights the first suggestion.
  • Enter selects the active suggestion.
  • Escape closes the list.
  • Loading and empty messages are announced.

If you want, I can turn this into a polished MDX post with a stronger introduction, a real-world example API, and a complete test file.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)