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:
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.
Building a Simple Search Bar
HTML Structure
<!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:
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
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 Structure for Advanced Search
<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
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:
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
| Bug | Cause | Fix |
|---|---|---|
| Search does nothing and shows no error | The script looks up an id that doesn't exist, and an if-check hides it | Check getElementById results; keep ids in HTML and JS identical |
| Empty list after searching from page 3 | Page not reset when the query or filter changes | Set page = 1 on every new search, and clamp to the last page |
| Blank area when nothing matches | No empty state | Show a "No results" message |
| "cafe" doesn't find "Café" | Accents compared literally | normalize("NFD") and strip combining marks |
| Old results replace new ones | Server responses arrive out of order | Abort the previous request |
| Script runs from a result's text | Results built with innerHTML | Use 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.
How do I add fuzzy search?
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".
Related Articles
View allAbout 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.




