Build a Dynamic Search Bar in JavaScript: Simple & Advanced

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
6 min read
Published Dec 24, 2024
Updated Oct 8, 2026
Learn how to build a Dynamic Search Bar with JavaScript: Simple and Advanced Banner Image
11 min read
975 words
21 sections7 code blocks

A dynamic search bar filters results while the user types. This guide builds two versions in plain JavaScript: a simple one that filters a list, and an advanced one with a category filter, pagination, and a URL you can share. A short section at the end covers searching a server instead of a local array.

Quick answer: the core of any search bar is an input listener that filters your data and re-renders the results:

JavaScript
input.addEventListener("input", () => {
  const query = input.value.toLowerCase().trim();
  const matches = items.filter((item) => item.toLowerCase().includes(query));
  list.replaceChildren(...matches.map((item) => {
    const li = document.createElement("li");
    li.textContent = item;
    return li;
  }));
});

The rest of this guide turns that into something you can ship: empty states, accents, labels for screen readers, page resets, and URL state.

Tested in: Chrome 152 on localhost, October 2026.

HTML Structure

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Simple Search Bar</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="search">
    <label for="search-input">Search fruits</label>
    <input id="search-input" type="search" placeholder="e.g. berry" autocomplete="off">
    <p id="message" class="message" aria-live="polite"></p>
    <ul id="results" class="results"></ul>
  </div>
  <script src="search.js"></script>
</body>
</html>
  • type="search" gives you a clear (×) button in most browsers and a search key on mobile keyboards.
  • A real <label> instead of only a placeholder, so screen readers announce what the field is for.
  • The aria-live="polite" message announces the result count after each search without stealing focus.

CSS Styling

One stylesheet for both versions in this guide:

CSS
body {
  font-family: system-ui, sans-serif;
  background: #f4f4f9;
  margin: 0;
  padding: 40px 16px;
}

.search {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.search label {
  display: block;
  margin-bottom: 6px;
  font-weight: 600;
}

.search input,
.search select {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 14px;
  margin-bottom: 12px;
  font-size: 16px;
  border: 2px solid #ddd;
  border-radius: 8px;
}

.search input:focus,
.search select:focus {
  border-color: #6200ea;
  outline: none;
}

.message {
  color: #555;
  margin: 0 0 8px;
}

.results {
  list-style: none;
  padding: 0;
  margin: 0;
}

.results li {
  padding: 12px;
  margin: 6px 0;
  background: #f9f9f9;
  border: 1px solid #eee;
  border-radius: 8px;
}

.results .category {
  color: #777;
  font-size: 14px;
  margin-left: 8px;
}

.pagination {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  margin-top: 16px;
}

.pagination button {
  padding: 8px 14px;
  border: 1px solid #6200ea;
  border-radius: 6px;
  background: #fff;
  color: #6200ea;
  cursor: pointer;
}

.pagination button[aria-current="page"] {
  background: #6200ea;
  color: #fff;
}

.pagination button:disabled {
  opacity: 0.4;
  cursor: default;
}

Inputs use font-size: 16px. iOS Safari zooms into inputs with a smaller font size when they're focused.

JavaScript Implementation

JavaScript
const input = document.getElementById("search-input");
const results = document.getElementById("results");
const message = document.getElementById("message");

const fruits = [
  "Açaí", "Apple", "Apricot", "Banana", "Blackberry", "Blueberry",
  "Cherry", "Coconut", "Grape", "Kiwi", "Mango", "Orange",
  "Papaya", "Peach", "Pear", "Pineapple", "Raspberry", "Strawberry",
];

// Lowercase and strip accents, so "acai" finds "Açaí"
function normalize(text) {
  return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
}

// Normalize the data once, not on every keystroke
const index = fruits.map((name) => ({ name, key: normalize(name) }));

function render(matches) {
  results.replaceChildren(
    ...matches.map(({ name }) => {
      const li = document.createElement("li");
      li.textContent = name;
      return li;
    })
  );
  message.textContent =
    matches.length === 0
      ? "No results found."
      : `${matches.length} result${matches.length === 1 ? "" : "s"}`;
}

function search() {
  const query = normalize(input.value);
  render(index.filter((fruit) => fruit.key.includes(query)));
}

input.addEventListener("input", search);
search();
  • normalize() lowercases, trims and strips accents, so "acai" finds "Açaí" and "jalapeno" finds "Jalapeño".
  • The data is normalized once into an index, not on every keystroke.
  • textContent and replaceChildren build the list without innerHTML, so a result containing HTML is shown as text, not run.
  • Empty state: "No results found." instead of a blank area.

Advanced Search Bar with Filters and Pagination

The advanced version searches products, filters them by category, shows five per page, and keeps all three values in the URL (?q=pe&category=vegetable&page=2) so a search survives a reload and can be shared.

HTML
<div class="search">
  <label for="product-search">Search products</label>
  <input id="product-search" type="search" autocomplete="off">

  <label for="category">Category</label>
  <select id="category">
    <option value="all">All categories</option>
    <option value="fruit">Fruit</option>
    <option value="vegetable">Vegetable</option>
  </select>

  <p id="message" class="message" aria-live="polite"></p>
  <ul id="results" class="results"></ul>
  <nav id="pagination" class="pagination" aria-label="Search result pages"></nav>
</div>

Load styles.css from above and advanced.js below.

JavaScript Implementation

JavaScript
const PAGE_SIZE = 5;

const input = document.getElementById("product-search");
const categorySelect = document.getElementById("category");
const results = document.getElementById("results");
const message = document.getElementById("message");
const pagination = document.getElementById("pagination");

function normalize(text) {
  return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
}

const products = [
  { name: "Apple", category: "fruit" },
  { name: "Apricot", category: "fruit" },
  { name: "Asparagus", category: "vegetable" },
  { name: "Banana", category: "fruit" },
  { name: "Broccoli", category: "vegetable" },
  { name: "Carrot", category: "vegetable" },
  { name: "Cauliflower", category: "vegetable" },
  { name: "Cherry", category: "fruit" },
  { name: "Cucumber", category: "vegetable" },
  { name: "Eggplant", category: "vegetable" },
  { name: "Grape", category: "fruit" },
  { name: "Jalapeño", category: "vegetable" },
  { name: "Kiwi", category: "fruit" },
  { name: "Lettuce", category: "vegetable" },
  { name: "Mango", category: "fruit" },
  { name: "Orange", category: "fruit" },
  { name: "Pear", category: "fruit" },
  { name: "Pepper", category: "vegetable" },
  { name: "Pineapple", category: "fruit" },
  { name: "Potato", category: "vegetable" },
  { name: "Spinach", category: "vegetable" },
  { name: "Strawberry", category: "fruit" },
].map((product) => ({ ...product, key: normalize(product.name) }));

const state = { query: "", category: "all", page: 1 };

// Restore the search from the URL, e.g. ?q=pe&category=fruit&page=2
function readUrl() {
  const params = new URLSearchParams(location.search);
  state.query = params.get("q") ?? "";
  state.category = params.get("category") ?? "all";
  state.page = Math.max(1, Number.parseInt(params.get("page"), 10) || 1);

  input.value = state.query;
  categorySelect.value = state.category;
  if (categorySelect.value !== state.category) {
    state.category = "all"; // unknown category in the URL
    categorySelect.value = "all";
  }
}

// Keep the URL in sync so the search can be shared or reloaded
function writeUrl() {
  const params = new URLSearchParams();
  if (state.query) params.set("q", state.query);
  if (state.category !== "all") params.set("category", state.category);
  if (state.page > 1) params.set("page", state.page);
  const qs = params.toString();
  history.replaceState(null, "", qs ? `?${qs}` : location.pathname);
}

function getMatches() {
  const query = normalize(state.query);
  return products.filter(
    (p) => p.key.includes(query) && (state.category === "all" || p.category === state.category)
  );
}

function renderPagination(totalPages) {
  pagination.replaceChildren();
  if (totalPages <= 1) return;

  const button = (label, page, { current = false, disabled = false } = {}) => {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = label;
    btn.dataset.page = page;
    btn.disabled = disabled;
    if (current) btn.setAttribute("aria-current", "page");
    return btn;
  };

  pagination.append(button("Previous", state.page - 1, { disabled: state.page === 1 }));
  for (let page = 1; page <= totalPages; page++) {
    pagination.append(button(String(page), page, { current: page === state.page }));
  }
  pagination.append(button("Next", state.page + 1, { disabled: state.page === totalPages }));
}

function render() {
  const matches = getMatches();
  const totalPages = Math.max(1, Math.ceil(matches.length / PAGE_SIZE));
  state.page = Math.min(state.page, totalPages); // never show an empty page

  const start = (state.page - 1) * PAGE_SIZE;
  const pageItems = matches.slice(start, start + PAGE_SIZE);

  results.replaceChildren(
    ...pageItems.map((p) => {
      const li = document.createElement("li");
      const category = document.createElement("span");
      category.className = "category";
      category.textContent = p.category;
      li.append(p.name, category);
      return li;
    })
  );

  message.textContent =
    matches.length === 0
      ? "No products match your search."
      : `Showing ${start + 1}–${start + pageItems.length} of ${matches.length}`;

  renderPagination(totalPages);
  writeUrl();
}

input.addEventListener("input", () => {
  state.query = input.value;
  state.page = 1; // a new search starts on page 1
  render();
});

categorySelect.addEventListener("change", () => {
  state.category = categorySelect.value;
  state.page = 1;
  render();
});

pagination.addEventListener("click", (e) => {
  const btn = e.target.closest("button");
  if (!btn || btn.disabled) return;
  state.page = Number(btn.dataset.page);
  render();
});

readUrl();
render();

How It Works

  • One state object (query, category, page) drives everything. Every change updates the state and calls render().
  • A new search or category resets to page 1. Without this, searching while on page 3 shows an empty list when the new results fit on one page.
  • The page is clamped to the last page that exists, so a stale ?page=9 link still shows results.
  • Previous/Next buttons are disabled at the ends, and the current page is marked with aria-current="page" for screen readers and styling.
  • URL state: readUrl() restores the search on load; writeUrl() uses history.replaceState so typing doesn't fill the back button with one entry per keystroke. An unknown category in the URL falls back to "All".
  • One click listener on the pagination container handles every page button, including the ones created on the next render.

Searching a Server or API

When the data is too large to load into the browser, send the query to the server. Two things become essential: a debounce, so you send one request per pause instead of one per keystroke, and an AbortController, so a slow response for an old query can't overwrite newer results. This replaces the input listener in search.js and reuses its render() function:

JavaScript
let timer;
let controller;

input.addEventListener("input", () => {
  clearTimeout(timer);
  timer = setTimeout(async () => {
    controller?.abort(); // cancel the previous request
    controller = new AbortController();
    const query = input.value.trim();
    try {
      const res = await fetch(
        `https://dummyjson.com/products/search?q=${encodeURIComponent(query)}&limit=20&select=title`,
        { signal: controller.signal }
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      render(data.products.map((p) => ({ name: p.title })));
    } catch (err) {
      if (err.name !== "AbortError") message.textContent = "Search failed. Try again.";
    }
  }, 300);
});

In our test, typing "ph", "pho", "phone" quickly sent one request. A failed request shows a message instead of leaving old results on screen. For a dropdown of suggestions built on the same idea, see How to Implement a Searchable Dropdown in JavaScript.

Performance with Large Datasets

  • Up to a few thousand items: filter in the browser. Filtering is fast; creating DOM nodes is what costs time, so paginate or cap how many results you render.
  • Normalize once, as the index above does, instead of calling toLowerCase() on every item for every keystroke.
  • Larger or frequently changing data: search on the server with debounce and AbortController.
  • Typos: includes() only finds exact substrings. For "aple" → "Apple", use a fuzzy search library such as Fuse.js.

Common Search Bar Bugs

BugCauseFix
Search does nothing and shows no errorThe script looks up an id that doesn't exist, and an if-check hides itCheck getElementById results; keep ids in HTML and JS identical
Empty list after searching from page 3Page not reset when the query or filter changesSet page = 1 on every new search, and clamp to the last page
Blank area when nothing matchesNo empty stateShow a "No results" message
"cafe" doesn't find "Café"Accents compared literallynormalize("NFD") and strip combining marks
Old results replace new onesServer responses arrive out of orderAbort the previous request
Script runs from a result's textResults built with innerHTMLUse textContent

FAQs

How do I make a search bar in JavaScript?

Listen for the input event on a text field, filter your data with includes() on a lowercased query, and re-render the matching items. The simple version above is a complete example.

How do I search a large dataset?

For a few thousand items, filter in the browser and paginate the results. For more, or when the data lives in a database, send the query to the server with a debounce and an AbortController.

Should I search on every keystroke?

For a local array, yes — it's fast and feels instant. For server requests, debounce by about 300 ms so you send one request per pause in typing.

How do I handle no results or an empty query?

Show a clear message such as "No results found." when nothing matches. For an empty query, showing all items (or popular items) is usually better than an empty page.

How do I keep the search in the URL?

Read the query with new URLSearchParams(location.search) on load and write it back with history.replaceState after each change, as the advanced example does.

How do I handle special characters in search queries?

When sending a query to a server, encode it with encodeURIComponent() or build the URL with URLSearchParams. When showing results, use textContent so characters like < are displayed, not interpreted.

Use a library such as Fuse.js, which ranks results by similarity and tolerates typos. Plain includes() only matches exact substrings.

Can I use infinite scrolling instead of pagination?

Yes. Render the first page, then use an IntersectionObserver on a sentinel element below the list to append the next page when it scrolls into view. Pagination is easier to link to and to navigate with a keyboard.

How do I make the search bar accessible?

Give the input a visible <label>, use type="search", announce the result count in an aria-live="polite" region, and mark the current page with aria-current="page".

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About Muhaymin Bin Mehmood

Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.