Skip to content

JavaScript Tutorial Practical Projects

Project 9: A Search and Filter UI

Lesson 248 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

Searching and filtering a list is something nearly every site needs. This project combines a text search with category buttons.

index.html
HTML
<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 &quot;garden&quot;" 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: category and query. render() always computes the result from both.
  • filter with 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-pressed on 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 with textContent, 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

  1. Add a sort menu (title, year).
  2. Add a year range filter.
  3. Show a “Clear filters” button when something is filtered.
  4. Remember the last search in localStorage.