Searching and filtering a list is something nearly every site needs. This project combines a text search with category buttons.
<style>
.search { font-family: system-ui, sans-serif; max-width: 340px; }
input { width: 100%; box-sizing: border-box; padding: 8px; font: inherit; }
.cats { display: flex; gap: 6px; margin: 8px 0; flex-wrap: wrap; }
.cats button { font: inherit; padding: 4px 12px; border: 2px solid #1a5276; border-radius: 999px; background: #fff; cursor: pointer; }
.cats button[aria-pressed="true"] { background: #1a5276; color: #fff; }
ul { padding: 0; list-style: none; }
li { padding: 8px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
mark { background: #fdebd9; }
.empty { color: #555; }
</style>
<div class="search">
<label for="q">Search books</label>
<input id="q" type="search" placeholder="Try "garden"" autocomplete="off">
<div class="cats" id="cats" role="group" aria-label="Category"></div>
<p id="count" role="status"></p>
<ul id="results"></ul>
</div>
<script>
const books = [
{ title: "The Slow Garden", category: "Fiction", year: 2021 },
{ title: "Night Trains", category: "Fiction", year: 2019 },
{ title: "Salt and Paper", category: "Poetry", year: 2022 },
{ title: "A Garden of Verses", category: "Poetry", year: 2018 },
{ title: "Walking Lisbon", category: "Travel", year: 2020 },
{ title: "Garden Cities of Europe", category: "Travel", year: 2017 }
];
const categories = ["All", ...new Set(books.map(b => b.category))];
let category = "All";
let query = "";
const results = document.querySelector("#results");
const countEl = document.querySelector("#count");
const catsEl = document.querySelector("#cats");
function highlight(text, term) {
const fragment = document.createDocumentFragment();
const index = term ? text.toLowerCase().indexOf(term.toLowerCase()) : -1;
if (index === -1) { fragment.append(text); return fragment; }
fragment.append(text.slice(0, index));
const mark = document.createElement("mark");
mark.textContent = text.slice(index, index + term.length);
fragment.append(mark, text.slice(index + term.length));
return fragment;
}
function render() {
const term = query.trim().toLowerCase();
const matches = books.filter(book =>
(category === "All" || book.category === category) &&
book.title.toLowerCase().includes(term)
);
results.replaceChildren(...matches.map(book => {
const li = document.createElement("li");
const title = document.createElement("span");
title.append(highlight(book.title, query.trim()));
const meta = document.createElement("small");
meta.textContent = `${book.category}, ${book.year}`;
li.append(title, meta);
return li;
}));
countEl.textContent = matches.length === 0
? "No books found. Try a different word or category."
: `${matches.length} book${matches.length === 1 ? "" : "s"} found`;
}
for (const name of categories) {
const button = document.createElement("button");
button.textContent = name;
button.setAttribute("aria-pressed", String(name === category));
catsEl.append(button);
}
catsEl.addEventListener("click", event => {
const button = event.target.closest("button");
if (!button) return;
category = button.textContent;
catsEl.querySelectorAll("button").forEach(b => b.setAttribute("aria-pressed", String(b === button)));
render();
});
document.querySelector("#q").addEventListener("input", event => {
query = event.target.value;
render();
});
render();
</script>
How it works
- State:
categoryandquery.render()always computes the result from both. filterwith two conditions: the category (or “All”) and the text match, case-insensitive (filter, includes).- The category list is built from the data with a
Set, so adding a book with a new category adds a button. aria-pressedon the buttons shows which is active, and the result count is a status message that also covers the empty state.- The match is highlighted with a real
<mark>element built withtextContent, so user text can never become HTML (Safe Output).
Scaling up
- For a few hundred items, filtering on every keystroke is instant. For thousands, add debouncing (Debouncing).
- For very large data, filter on the server and fetch results (fetch).
- Keep the search in the URL (
?q=garden&category=Fiction) so it can be shared (URLSearchParams).
Try changing it
- Add a sort menu (title, year).
- Add a year range filter.
- Show a “Clear filters” button when something is filtered.
- Remember the last search in
localStorage.