Bismillâh. Yesterday the grid held six products. Today we'll add a search box that filters them as the user types, with ten lines of JavaScript. No Lunr, no Fuse, no backend. Just the browser doing what it's already good at: reading text and toggling classes.

The idea

Each product card carries a data-keys attribute with words the user might search for: "walnut table wood furniture" on the walnut table card, "linen lamp light" on the lamp, and so on. When the user types in the search box, we walk the cards and hide the ones whose keys don't match. That's it. Ten lines of JavaScript, zero dependencies.

Ten lines

const input = document.querySelector('#q')
const products = Array.from(document.querySelectorAll('.p'))

input.addEventListener('input', () => {
  const q = input.value.trim().toLowerCase()
  products.forEach(el => {
    const keys = (el.dataset.keys || '').toLowerCase()
    const match = q === '' || keys.includes(q)
    el.classList.toggle('hidden', !match)
  })
})

Read it piece by piece:

  • Array.from(document.querySelectorAll('.p')) turns the live NodeList into a regular array so we can use forEach, map, filter on it later without surprises.
  • input.value.trim().toLowerCase() normalizes the query: no leading spaces, no case sensitivity.
  • keys.includes(q) is a substring test. Typing "walnut" matches "walnut table wood furniture". Typing "table" does too. This is the simplest possible fuzzy search: substring match.
  • classList.toggle('hidden', !match) adds the hidden class when the card doesn't match, removes it when it does. CSS handles the visual: .p.hidden { display: none; }.

Debouncing: don't run the filter 30 times per second

Typing "walnut" fires the input event six times, one for each keystroke. For six products it doesn't matter, but once the shop has 600 products you'll feel the stutter. Debouncing batches rapid events into one call:

let t = null
input.addEventListener('input', () => {
  clearTimeout(t)
  t = setTimeout(applyFilter, 80)
})

setTimeout schedules applyFilter to run 80 ms from now. Each new keystroke cancels the previous timeout and queues a new one. When the user pauses for 80 ms, the filter runs once. The number 80 is a rhythm; try 50 for very fast machines, 150 for very slow ones. Below 40 ms you start firing while the user is still mid-keystroke.

Empty state

When no products match, show a short line. The demo keeps an <p class="empty" hidden> and toggles its hidden attribute based on the match count. Empty states are the cheapest trust signal: without one, the user thinks the site crashed.

const empty = document.querySelector('#empty')
let shown = 0
products.forEach(el => {
  if (match) shown++
})
empty.hidden = shown !== 0 || q === ''

Counter: "3 of 6"

A small counter near the search box tells the user what's happening: "3 of 6 products." Three characters of UI, a huge amount of trust. The demo updates this after every filter pass.

Search-first: data-keys over content

Why data-keys on the card and not just querySelector('h3').textContent? Two reasons:

  • Synonyms. A user searching "wood" should find the walnut table even though the title says "Walnut Table". The data-keys attribute carries the searchable vocabulary: "walnut table wood furniture".
  • Performance. Reading a data attribute is one DOM access; walking text content of every child element is many. At six products it's free, at 600 the difference shows.

You can, of course, include the title text inside data-keys so you don't duplicate logic. The demo matches on both for safety.

When to reach for a library

Substring match is a floor. For real fuzzy search (typo tolerance, relevance scoring, word boosting), reach for:

  • FlexSearch or MiniSearch for client-side, they ship under 10 KB.
  • Typesense or Meilisearch for a small self-hosted index.
  • Algolia or similar cloud providers for "just make it work."

But the threshold for needing any of these is higher than most people think. A catalog of 100 products with data-keys performs beautifully on this simple approach. A catalog of 10,000 with fuzzy matching and relevance, yes, you need help.

Tricks worth carrying

  • Pair type="search" with autocomplete="off". Browsers give type="search" a nice small clear button (the "x") on focus. Free UX.
  • Don't filter on focus events. Only input. focus fires when the user tabs in with no new text; running the filter again wastes cycles.
  • Use display: none, not opacity: 0. Opacity keeps the card in the grid layout; display: none removes it and lets the remaining cards reflow to fill the row. The visual feel is completely different.

Takeaways

  1. Ten lines of JavaScript + one data attribute per card = usable search.
  2. Debounce at 80 ms to handle 500+ products without stutter.
  3. Always show an empty state and a match counter.
  4. Reach for a library only when substring match starts to fail.

Next post: "A slide-out cart drawer that feels native, with only transform and transition."

Filter honestly, kardeşim. Show what matches, nothing more.