Project 11 · Intermediate
Search Filter
A list of countries that shrinks as you type. You'll build it twice: once by hiding the items already on the page, and once by keeping the countries as data, searching capitals too, and highlighting what matched.
- 3 HTML ideas
- 1 CSS idea
- 8 JavaScript ideas
- 2 ways to build it
Why build it
Filtering a list as someone types lets you build a shop's product search, a searchable team directory, a country picker in a form, or a filter for a long FAQ. Building it both ways shows when hiding what's already on the page is enough, and when you need to filter the data itself, as you will once a list comes from a server.
Skills you'll earn
- Run code on every keystroke with the input event
- Match text whether it's typed in capitals or not
- Keep only the items you want from an array with filter
- Highlight the matching text safely, without innerHTML
- Tell screen readers how many results there are as they change
01
Try it
Play with it first, then read the code that makes it work.
Every country is already in the HTML. JavaScript checks each one as you type and hides the ones that don't match.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="search">
<h1 class="search-title">Find a country</h1>
<label class="search-label" for="search-input">Search countries</label>
<!-- type="search" adds a clear button and a search key on phones. -->
<input
class="search-input"
type="search"
id="search-input"
autocomplete="off"
/>
<!-- The count is announced, so screen reader users hear what's left. -->
<p class="search-count" id="search-count" aria-live="polite">
12 countries
</p>
<!-- Every country is already in the page; JavaScript only hides some. -->
<ul class="results" id="results">
<li>Brazil</li>
<li>Canada</li>
<li>Egypt</li>
<li>France</li>
<li>Ghana</li>
<li>India</li>
<li>Japan</li>
<li>Kenya</li>
<li>Mexico</li>
<li>Nigeria</li>
<li>Norway</li>
<li>Peru</li>
</ul>
<p class="no-results" id="no-results" hidden>
No countries match that search.
</p>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
Every country is already in the HTML. JavaScript checks each one as you type and hides the ones that don't match.
HTMLthe structure
- 01
Use
type="search"with a labeltype="search"is a text input meant for searching. Most browsers add a button to clear it, and phones show a search key on the keyboard. The<label>gives the field its name. A placeholder can't do that job reliably: it disappears once you type, and not every screen reader reads it.<label class="search-label" for="search-input">Search countries</label> <!-- type="search" adds a clear button and a search key on phones. --> <input class="search-input" type="search" id="search-input" autocomplete="off" /> - 02
Announce how many match
As you type, sighted users can see the list shrink, but screen reader users get no sign that anything changed. The
<p>above the list says how many countries match, like "2 countries", andaria-live="polite"makes screen readers read the new number each time it changes, without interrupting.<!-- The count is announced, so screen reader users hear what's left. --> <p class="search-count" id="search-count" aria-live="polite"> 12 countries </p>
JavaScriptthe behaviour
- 03
React as you type with the
inputeventEvents are the browser's way of telling your code that something happened. The
inputevent fires every time the field's value changes: typing, deleting, pasting, or pressing the clear button. That's why the list updates as you type. Thechangeevent would only fire once you leave the field, which is too late for a live search.// "input" fires on every keystroke, paste, and clear, not just on Enter. input.addEventListener("input", () => { - 04
Match without caring about capitals
includes()cares about capitals, so "gha" wouldn't find "Ghana" on its own.toLowerCase()makes a lowercase copy of some text, and using it on both the query and each country means "gha", "GHA", and "Gha" all match.trim()removes spaces from both ends of what was typed, so a stray space doesn't stop a match.// Lowercase both sides, so "gha" finds "Ghana". const query = input.value.trim().toLowerCase(); - 05
Check each item with
includes()forEach()runs a function once for every<li>in the list.includes()returnstrueif one piece of text appears anywhere in another, so "an" finds Canada, France, Ghana, and Japan. Setting an element'shiddenproperty totruehides it, so each country that doesn't match disappears from the list.items.forEach((item) => { // includes() is true if the text contains the query anywhere. const isMatch = item.textContent.toLowerCase().includes(query); item.hidden = !isMatch; if (isMatch) matches++; }); - 06
Show a message when nothing matches
An empty list with no explanation looks broken.
matchesstarts at 0 and goes up by one for every country that matches. After the loop, that number fills the count text, and the "No countries match" message is shown only when it's still 0.let matches = 0; items.forEach((item) => { // includes() is true if the text contains the query anywhere. const isMatch = item.textContent.toLowerCase().includes(query); item.hidden = !isMatch; if (isMatch) matches++; }); countDisplay.textContent = `${matches} ${matches === 1 ? "country" : "countries"}`; noResults.hidden = matches > 0;
HTMLthe structure
- 01
Start with an empty list
The HTML is the same as Hide what doesn't match, except the
<ul>has no countries in it. The countries live in JavaScript now, and every time you type, the list is drawn again from the ones that match. Starting empty means there's only one copy of the countries to keep up to date, in the script.<!-- Empty to start: JavaScript draws the matching countries here. --> <ul class="results" id="results"></ul>
CSSthe look
- 02
Highlight with
<mark><mark>is the HTML element for text that's highlighted because it's relevant, like a search match, and some screen readers announce it. Browsers give it a yellow background by default. The CSS sets a softer yellow, rounds the corners, and usescolor: inheritso the letters keep the same colour as the words around them./* <mark> highlights the matching letters; this sets its colour. */ mark { border-radius: 0.2rem; background: #fde68a; color: inherit; }
JavaScriptthe behaviour
- 03
Keep the list as an array of objects
countriesis an array of objects, and each object has anameand acapital. Keeping the list as data rather than HTML means each country can carry more than the text on screen, and the search can look at any of those fields. It's also the shape a list usually has when it comes from a server.// The countries are data now, not HTML, so each can carry more than a name. const countries = [ { name: "Brazil", capital: "Brasília" }, { name: "Canada", capital: "Ottawa" }, { name: "Egypt", capital: "Cairo" }, { name: "France", capital: "Paris" }, { name: "Ghana", capital: "Accra" }, { name: "India", capital: "New Delhi" }, { name: "Japan", capital: "Tokyo" }, { name: "Kenya", capital: "Nairobi" }, { name: "Mexico", capital: "Mexico City" }, { name: "Nigeria", capital: "Abuja" }, { name: "Norway", capital: "Oslo" }, { name: "Peru", capital: "Lima" }, ]; - 04
Highlight matches without
innerHTMLhighlight()wraps the matching letters in a<mark>.indexOf()returns the position where the query starts in the text, or -1 if it isn't there.slice()copies part of a string, so the text is cut into before, match, and after. The match goes in a<mark>made withcreateElement, so no text is ever treated as HTML.// Wrap the matching part in <mark>. It's built from nodes, not an HTML // string, so no country name can ever be read as code. function highlight(text, query) { const start = text.toLowerCase().indexOf(query); // No search, or no match in this text: return the text as it is. if (!query || start === -1) return [text]; const end = start + query.length; const mark = document.createElement("mark"); mark.textContent = text.slice(start, end); return [text.slice(0, start), mark, text.slice(end)]; } - 05
Redraw from the matches each time
createResult()builds one<li>from a country: a<span>for the name and one for the capital, each with its match highlighted.renderResults()swaps the whole list for these new items withreplaceChildren(), then updates the count and the message. It runs on every keystroke, andrenderResults("")runs once at the start to draw every country.function createResult(country, query) { const item = document.createElement("li"); const name = document.createElement("span"); name.append(...highlight(country.name, query)); const capital = document.createElement("span"); capital.className = "result-capital"; capital.append(...highlight(country.capital, query)); item.append(name, capital); return item; } function renderResults(query) { // filter keeps only the countries whose name or capital matches. const matches = countries.filter( (country) => country.name.toLowerCase().includes(query) || country.capital.toLowerCase().includes(query), ); results.replaceChildren(...matches.map((country) => createResult(country, query))); countDisplay.textContent = `${matches.length} ${matches.length === 1 ? "country" : "countries"}`; noResults.hidden = matches.length > 0; } input.addEventListener("input", () => { renderResults(input.value.trim().toLowerCase()); }); renderResults(""); - 06
Find matches with
filterand||filter()makes a new array of only the items its function returnstruefor.||means "or": the result istrueif either side is. So a country is kept when its name or its capital contains the query, which is how searching "par" finds France through its capital, Paris.function renderResults(query) { // filter keeps only the countries whose name or capital matches. const matches = countries.filter( (country) => country.name.toLowerCase().includes(query) || country.capital.toLowerCase().includes(query), );
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Wait until typing pauses before filtering, a technique called debouncing.
- Challenge 02
Add buttons to filter by continent as well as by text.
- Challenge 03
Keep the search in the page's address with URLSearchParams, so a filtered list can be shared.
Finished Search Filter?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)