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 useforEach,map,filteron 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 thehiddenclass 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-keysattribute 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"withautocomplete="off". Browsers givetype="search"a nice small clear button (the "x") on focus. Free UX. -
Don't filter on
focusevents. Onlyinput.focusfires when the user tabs in with no new text; running the filter again wastes cycles. -
Use
display: none, notopacity: 0. Opacity keeps the card in the grid layout;display: noneremoves it and lets the remaining cards reflow to fill the row. The visual feel is completely different.
Takeaways
- Ten lines of JavaScript + one data attribute per card = usable search.
- Debounce at 80 ms to handle 500+ products without stutter.
- Always show an empty state and a match counter.
- 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.