Build a Responsive Product Filter with Vanilla JavaScript
Challenge Goal
Build a responsive product filter using HTML, CSS, and Vanilla JavaScript. Users should be able to search products by name, filter them by category, set a maximum price, and instantly see matching products without reloading the page.
This challenge helps you practice working with arrays, DOM events, form controls, reusable functions, responsive layouts, and dynamically rendered content.
What You Will Build
You will create a small product catalog containing several products. The interface will include:
- A text input for searching products by name.
- A category dropdown.
- A maximum-price input.
- A responsive product grid.
- A message when no products match the selected filters.
- A reset button that restores the default view.
Product Data
Use the following JavaScript array as your starting product data:
const products = [
{ id: 1, name: "Wireless Mouse", category: "Electronics", price: 25 },
{ id: 2, name: "Mechanical Keyboard", category: "Electronics", price: 75 },
{ id: 3, name: "Coffee Mug", category: "Home", price: 12 },
{ id: 4, name: "Desk Lamp", category: "Home", price: 35 },
{ id: 5, name: "Running Shoes", category: "Clothing", price: 60 },
{ id: 6, name: "Cotton T-Shirt", category: "Clothing", price: 20 }
];
Challenge Requirements
- Display all products when the page first loads.
- Allow users to search products by name.
- Make the search case-insensitive.
- Allow users to filter products by category.
- Include an
All Categoriesoption that disables category filtering. - Allow users to enter a maximum price.
- Apply all active filters together.
- Update the product list immediately whenever a filter changes.
- Display a clear empty-state message when no products match.
- Add a reset button that clears every filter and displays all products again.
- Make the product grid responsive so it works on mobile, tablet, and desktop screens.
Input and Output
Input
The user can provide three types of filter input:
- A search phrase such as
mouse. - A category such as
Electronics. - A maximum price such as
50.
Output
The page should display only products that satisfy every active filter.
For example, if the category is Electronics and the maximum price is 50, the result should contain only:
Wireless Mouse - Electronics - $25
Constraints and Edge Cases
- An empty search field should match every product name.
- Selecting
All Categoriesshould match every category. - An empty maximum-price field should not limit products by price.
- The maximum price should never cause the page to crash if the value is missing or invalid.
- Search input should ignore uppercase and lowercase differences.
- If no products match, show an empty-state message instead of leaving the interface blank.
- Do not reload the page when filters change.
Suggested HTML Structure
<div class="filters">
<input id="searchInput" type="search" placeholder="Search products">
<select id="categoryFilter">
<option value="all">All Categories</option>
<option value="Electronics">Electronics</option>
<option value="Home">Home</option>
<option value="Clothing">Clothing</option>
</select>
<input id="maxPrice" type="number" min="0" placeholder="Maximum price">
<button id="resetButton" type="button">Reset Filters</button>
</div>
<p id="resultCount"></p>
<div id="productGrid" class="product-grid"></div>
<p id="emptyState" hidden>No products found.</p>
Responsive Layout
Your interface should remain easy to use on smaller screens. A simple CSS Grid layout is enough for this challenge.
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
Starter Code
Complete the filtering and rendering logic below:
const products = [
{ id: 1, name: "Wireless Mouse", category: "Electronics", price: 25 },
{ id: 2, name: "Mechanical Keyboard", category: "Electronics", price: 75 },
{ id: 3, name: "Coffee Mug", category: "Home", price: 12 },
{ id: 4, name: "Desk Lamp", category: "Home", price: 35 },
{ id: 5, name: "Running Shoes", category: "Clothing", price: 60 },
{ id: 6, name: "Cotton T-Shirt", category: "Clothing", price: 20 }
];
const searchInput = document.querySelector("#searchInput");
const categoryFilter = document.querySelector("#categoryFilter");
const maxPriceInput = document.querySelector("#maxPrice");
const resetButton = document.querySelector("#resetButton");
const productGrid = document.querySelector("#productGrid");
const emptyState = document.querySelector("#emptyState");
const resultCount = document.querySelector("#resultCount");
function filterProducts() {
// Read the current filter values.
// Filter the products array.
// Pass the filtered products to renderProducts().
}
function renderProducts(items) {
// Clear the existing product cards.
// Render one card for each product.
// Update the result count and empty state.
}
searchInput.addEventListener("input", filterProducts);
categoryFilter.addEventListener("change", filterProducts);
maxPriceInput.addEventListener("input", filterProducts);
resetButton.addEventListener("click", () => {
// Clear all filters and display every product again.
});
renderProducts(products);
Hints
- Use
Array.prototype.filter()to create the filtered product list. - Convert both the product name and search text to lowercase before comparing them.
- Use
String.prototype.includes()for partial name matching. - Check whether the selected category is
allbefore applying the category condition. - Convert the maximum-price value to a number only when the field contains a value.
- Create one Boolean variable for each filter condition to make your logic easier to read.
- Use
map()andjoin("")to generate multiple product cards.
Solution Walkthrough
- Start by rendering the complete
productsarray. - Whenever the user changes a filter, read the current values from the controls.
- Normalize the search value with
trim()andtoLowerCase(). - Use
filter()to test each product against the search, category, and price conditions. - A product should remain in the result only when every active condition evaluates to
true. - Pass the resulting array to a rendering function.
- The rendering function should update the product cards, result count, and empty-state visibility.
- The reset button should restore the default filter values and render all products again.
Final Solution
const products = [
{ id: 1, name: "Wireless Mouse", category: "Electronics", price: 25 },
{ id: 2, name: "Mechanical Keyboard", category: "Electronics", price: 75 },
{ id: 3, name: "Coffee Mug", category: "Home", price: 12 },
{ id: 4, name: "Desk Lamp", category: "Home", price: 35 },
{ id: 5, name: "Running Shoes", category: "Clothing", price: 60 },
{ id: 6, name: "Cotton T-Shirt", category: "Clothing", price: 20 }
];
const searchInput = document.querySelector("#searchInput");
const categoryFilter = document.querySelector("#categoryFilter");
const maxPriceInput = document.querySelector("#maxPrice");
const resetButton = document.querySelector("#resetButton");
const productGrid = document.querySelector("#productGrid");
const emptyState = document.querySelector("#emptyState");
const resultCount = document.querySelector("#resultCount");
function renderProducts(items) {
productGrid.innerHTML = items
.map(
(product) => `
<article class="product-card">
<h3>${product.name}</h3>
<p>Category: ${product.category}</p>
<p>Price: $${product.price.toFixed(2)}</p>
</article>
`
)
.join("");
resultCount.textContent = `${items.length} product${items.length === 1 ? "" : "s"} found`;
emptyState.hidden = items.length !== 0;
}
function filterProducts() {
const searchTerm = searchInput.value.trim().toLowerCase();
const selectedCategory = categoryFilter.value;
const maxPriceValue = maxPriceInput.value;
const filteredProducts = products.filter((product) => {
const matchesSearch = product.name.toLowerCase().includes(searchTerm);
const matchesCategory =
selectedCategory === "all" || product.category === selectedCategory;
const matchesPrice =
maxPriceValue === "" || product.price <= Number(maxPriceValue);
return matchesSearch && matchesCategory && matchesPrice;
});
renderProducts(filteredProducts);
}
searchInput.addEventListener("input", filterProducts);
categoryFilter.addEventListener("change", filterProducts);
maxPriceInput.addEventListener("input", filterProducts);
resetButton.addEventListener("click", () => {
searchInput.value = "";
categoryFilter.value = "all";
maxPriceInput.value = "";
renderProducts(products);
});
renderProducts(products);
Why This Solution Works
The solution keeps filtering and rendering as separate responsibilities. filterProducts() determines which products match the current controls, while renderProducts() controls what appears in the interface.
Each product is tested against three independent Boolean conditions. Because the final result uses &&, a product appears only when it satisfies every active filter.
Stretch Goals
- Add minimum-price and maximum-price filters.
- Add sorting by price from low to high and high to low.
- Add sorting by product name.
- Display the number of matching products.
- Generate category options automatically from the product array.
- Add product images and improve the card design.
- Add a mobile filter panel that can be expanded or collapsed.
- Store the selected filters in
localStorageso they remain after a page refresh. - Synchronize filters with URL query parameters so filtered views can be shared.
- Add pagination when the product catalog becomes large.
What You Practiced
- Filtering JavaScript arrays.
- Handling DOM events.
- Reading values from form controls.
- Combining multiple conditions.
- Rendering data dynamically.
- Handling empty states.
- Building responsive interfaces with CSS Grid.
- Separating filtering logic from presentation logic.