Building a Search Bar with JS Debouncing Guide

A complete guide to building a search bar with JavaScript debouncing. Covers a debounced input handler, live search with Fetch API, highlighting matched text, keyboard navigation through results, loading and empty states, AbortController for canceling stale requests, accessibility with ARIA attributes, and mobile-responsive styling.

JavaScriptintermediate
15 min read

A search bar with debouncing, live results, keyboard navigation, and accessibility is one of the most common UI components in web applications. This guide builds one from scratch using vanilla JavaScript, demonstrating how debouncing, the Fetch API, and DOM manipulation work together.

HTML Structure

htmlhtml
<div class="search-container" role="combobox" aria-expanded="false" aria-haspopup="listbox">
  <label for="search-input" class="sr-only">Search articles</label>
  <div class="search-input-wrapper">
    <svg class="search-icon" aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">
      <circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
      <line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/>
    </svg>
    <input
      id="search-input"
      type="search"
      placeholder="Search articles..."
      autocomplete="off"
      aria-autocomplete="list"
      aria-controls="search-results"
    >
    <button id="clear-btn" class="clear-btn" aria-label="Clear search" hidden>X</button>
  </div>
 
  <div id="search-results" class="search-results" role="listbox" hidden>
    <!-- Results injected by JavaScript -->
  </div>
 
  <div id="search-status" class="search-status" aria-live="polite"></div>
</div>

CSS Styling

csscss
.search-container {
  position: relative;
  max-width: 600px;
  margin: 0 auto;
}
 
.search-input-wrapper {
  display: flex;
  align-items: center;
  border: 2px solid #e2e8f0;
  border-radius: 8px;
  padding: 8px 12px;
  background: var(--bg-primary, #fff);
  transition: border-color 0.2s;
}
 
.search-input-wrapper:focus-within {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
 
#search-input {
  flex: 1;
  border: none;
  outline: none;
  font-size: 1rem;
  background: transparent;
  color: var(--text-primary, #1e293b);
  margin-left: 8px;
}
 
.search-icon { color: #94a3b8; }
 
.clear-btn {
  background: none;
  border: none;
  cursor: pointer;
  color: #94a3b8;
  font-size: 1rem;
  padding: 4px;
}
 
.search-results {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 4px;
  background: var(--bg-primary, #fff);
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  max-height: 400px;
  overflow-y: auto;
  z-index: 100;
}
 
.result-item {
  padding: 12px 16px;
  cursor: pointer;
  border-bottom: 1px solid #f1f5f9;
}
 
.result-item:hover,
.result-item.active {
  background: #f1f5f9;
}
 
.result-item mark {
  background: #fef08a;
  color: inherit;
  padding: 0 2px;
  border-radius: 2px;
}
 
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

Debounce Function

javascriptjavascript
function debounce(fn, delay) {
  let timeoutId;
 
  function debounced(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn.apply(this, args), delay);
  }
 
  debounced.cancel = () => {
    clearTimeout(timeoutId);
    timeoutId = null;
  };
 
  return debounced;
}

See debouncing in JavaScript a complete tutorial for advanced debounce patterns including leading mode and flush.

Search API Client

javascriptjavascript
class SearchClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.controller = null;
  }
 
  async search(query) {
    // Cancel any in-flight request
    if (this.controller) {
      this.controller.abort();
    }
    this.controller = new AbortController();
 
    const url = `${this.baseUrl}?q=${encodeURIComponent(query)}&limit=10`;
 
    const response = await fetch(url, {
      signal: this.controller.signal,
    });
 
    if (!response.ok) {
      throw new Error(`Search failed: ${response.status}`);
    }
 
    return response.json();
  }
 
  cancel() {
    if (this.controller) {
      this.controller.abort();
      this.controller = null;
    }
  }
}

See how to use the JS Fetch API complete tutorial for more on AbortController cancellation.

Text Highlighting

javascriptjavascript
function highlightMatch(text, query) {
  if (!query.trim()) return text;
 
  // Escape regex special characters in the query
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp(`(${escaped})`, "gi");
 
  return text.replace(regex, "<mark>$1</mark>");
}

Result Rendering

javascriptjavascript
function renderResults(results, query, container) {
  if (results.length === 0) {
    container.innerHTML = `
      <div class="no-results" role="option">
        <p>No results found for "${escapeHtml(query)}"</p>
      </div>
    `;
    return;
  }
 
  container.innerHTML = results
    .map(
      (result, index) => `
      <div class="result-item" role="option" id="result-${index}" data-url="${result.url}">
        <div class="result-title">${highlightMatch(escapeHtml(result.title), query)}</div>
        <div class="result-excerpt">${highlightMatch(escapeHtml(result.excerpt), query)}</div>
        <span class="result-category">${escapeHtml(result.category)}</span>
      </div>
    `
    )
    .join("");
}
 
function escapeHtml(str) {
  const div = document.createElement("div");
  div.textContent = str;
  return div.innerHTML;
}

Keyboard Navigation

javascriptjavascript
class KeyboardNavigator {
  constructor(container, onSelect) {
    this.container = container;
    this.onSelect = onSelect;
    this.activeIndex = -1;
  }
 
  handleKey(event) {
    const items = this.container.querySelectorAll(".result-item");
    if (items.length === 0) return;
 
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1);
        this.updateActive(items);
        break;
 
      case "ArrowUp":
        event.preventDefault();
        this.activeIndex = Math.max(this.activeIndex - 1, -1);
        this.updateActive(items);
        break;
 
      case "Enter":
        if (this.activeIndex >= 0 && items[this.activeIndex]) {
          event.preventDefault();
          this.onSelect(items[this.activeIndex]);
        }
        break;
 
      case "Escape":
        this.reset();
        break;
    }
  }
 
  updateActive(items) {
    items.forEach((item) => item.classList.remove("active"));
 
    if (this.activeIndex >= 0 && items[this.activeIndex]) {
      items[this.activeIndex].classList.add("active");
      items[this.activeIndex].scrollIntoView({ block: "nearest" });
    }
  }
 
  reset() {
    this.activeIndex = -1;
    this.container.querySelectorAll(".result-item").forEach((item) => {
      item.classList.remove("active");
    });
  }
}

ARIA Attributes Summary

ElementAttributePurpose
Container divrole="combobox"Identifies the widget as a combobox
Container divaria-expandedtrue when results are visible
Inputaria-autocomplete="list"Indicates suggestions appear in a list
Inputaria-controlsPoints to the results list ID
Results divrole="listbox"Identifies results as a selectable list
Result itemsrole="option"Each result is a selectable option
Status divaria-live="polite"Announces result count to screen readers

Main Search Component

javascriptjavascript
class SearchBar {
  constructor(options) {
    this.input = document.getElementById("search-input");
    this.results = document.getElementById("search-results");
    this.status = document.getElementById("search-status");
    this.clearBtn = document.getElementById("clear-btn");
    this.container = this.input.closest(".search-container");
 
    this.client = new SearchClient(options.apiUrl);
    this.navigator = new KeyboardNavigator(this.results, (item) => {
      window.location.href = item.dataset.url;
    });
 
    this.minQueryLength = options.minQueryLength || 2;
    this.debouncedSearch = debounce(this.performSearch.bind(this), 300);
 
    this.bindEvents();
  }
 
  bindEvents() {
    this.input.addEventListener("input", (e) => {
      const query = e.target.value.trim();
      this.clearBtn.hidden = query.length === 0;
 
      if (query.length < this.minQueryLength) {
        this.hideResults();
        return;
      }
 
      this.debouncedSearch(query);
    });
 
    this.input.addEventListener("keydown", (e) => {
      this.navigator.handleKey(e);
      if (e.key === "Escape") this.hideResults();
    });
 
    this.clearBtn.addEventListener("click", () => {
      this.input.value = "";
      this.clearBtn.hidden = true;
      this.hideResults();
      this.client.cancel();
      this.input.focus();
    });
 
    this.results.addEventListener("click", (e) => {
      const item = e.target.closest(".result-item");
      if (item) window.location.href = item.dataset.url;
    });
 
    document.addEventListener("click", (e) => {
      if (!this.container.contains(e.target)) {
        this.hideResults();
      }
    });
  }
 
  async performSearch(query) {
    this.showLoading();
 
    try {
      const data = await this.client.search(query);
      renderResults(data.results, query, this.results);
      this.showResults();
 
      this.status.textContent = `${data.results.length} result${data.results.length === 1 ? "" : "s"} found`;
      this.navigator.reset();
    } catch (error) {
      if (error.name === "AbortError") return; // Canceled, ignore
      this.results.innerHTML = '<div class="no-results"><p>Search failed. Try again.</p></div>';
      this.showResults();
    }
  }
 
  showLoading() {
    this.results.innerHTML = '<div class="loading-indicator"><p>Searching...</p></div>';
    this.showResults();
  }
 
  showResults() {
    this.results.hidden = false;
    this.container.setAttribute("aria-expanded", "true");
  }
 
  hideResults() {
    this.results.hidden = true;
    this.container.setAttribute("aria-expanded", "false");
    this.navigator.reset();
    this.status.textContent = "";
    this.debouncedSearch.cancel();
  }
}
 
// Initialize
const search = new SearchBar({ apiUrl: "/api/search" });

Performance Comparison

ApproachKeystrokes for "javascript"API CallsTime Saved
No debounce10100%
150ms debounce102-370-80%
300ms debounce101-280-90%
500ms debounce10190%
Rune AI

Rune AI

Key Insights

  • AbortController prevents stale results: Cancel the previous Fetch request before starting a new one so out-of-order responses never overwrite current results
  • 300ms debounce is the sweet spot: Balances responsiveness with API efficiency; 80-90% of unnecessary calls eliminated
  • Keyboard navigation is required for accessibility: Arrow keys, Enter to select, Escape to close; track activeIndex and update .active class
  • ARIA attributes make comboboxes screen-reader friendly: role="combobox", aria-expanded, aria-live="polite" for result count announcements
  • Escape HTML before highlighting: Always escapeHtml() first, then apply <mark> tags to prevent XSS from user-controlled search queries
RunePowered by Rune AI

Frequently Asked Questions

How do I handle the search results closing when clicking outside?

dd a `click` event listener on `document` that checks `container.contains(e.target)`. If the click is outside the search component, hide the results. This is shown in the `bindEvents` method above.

Should I debounce or throttle the search input?

Debounce is the correct choice for search inputs because you want to wait until the user stops typing. Throttling would send requests at regular intervals while the user is still typing, which wastes API calls. See [throttling in JavaScript a complete tutorial](/tutorials/programming-languages/javascript/throttling-in-javascript-a-complete-tutorial) for the throttle pattern.

What minimum query length should I require?

2-3 characters is standard. Single-character queries return too many results and overload the API. Set `minQueryLength` to 2 for general search or 3 for large datasets.

How do I prevent stale results from appearing?

Use `AbortController` to cancel the previous request before starting a new one. This ensures that only the latest query's results are rendered, even if responses arrive out of order.

How do I make the search bar accessible to screen readers?

Use `role="combobox"` on the container, `aria-expanded` to indicate when results are visible, `role="listbox"` on the results container, `role="option"` on each result, and `aria-live="polite"` on a status element that announces the result count.

Conclusion

A production search bar combines debouncing for input throttling, AbortController for stale request cancellation, keyboard navigation with arrow keys, mark tags for text highlighting, and ARIA attributes for screen reader support. The debounced handler fires 300ms after the last keystroke, the SearchClient cancels in-flight requests, and the KeyboardNavigator handles arrow keys independently. For the error handling layer, see advanced API error handling in JS full guide. For browser APIs used here like IntersectionObserver and element.scrollIntoView, see browser Web APIs in JavaScript complete guide.