Ever found yourself needing to compare a dozen options side by side — mobile plans, broadband packages, credit cards — and wished there was a simple filtering UI to narrow things down? You do not need React, Vue, or any framework for this. A well-structured vanilla JS comparison tool is fast, portable, and surprisingly straightforward to build.
In this tutorial, I will walk you through the core pattern I used to build real comparison tools for Malaysian broadband and mobile plans. You can see them live here:
Let us break down the pattern piece by piece.
1. Start with a Clean Data Structure
The foundation of any comparison tool is a flat array of objects. Each object represents one item (a plan, product, or service) with consistent keys.
const plans = [
{
name: "Unifi 300",
provider: "Telekom Malaysia",
speed: 300, // Mbps
price: 129, // MYR/month
contract: 24, // months
type: "fiber"
},
{
name: "Maxis Home Fibre 500",
provider: "Maxis",
speed: 500,
price: 199,
contract: 24,
type: "fiber"
},
// ... more plans
];
Keep values as numbers where possible (price, speed) so sorting and range filtering are easy. Use consistent string values for categorical fields like type or provider.
2. Build the Filter UI
Filters are just HTML inputs and selects. The key insight: every filter reads from the same plans array and writes to the same render function.
<div class="filters">
<select id="filter-provider">
<option value="">All Providers</option>
<option value="Telekom Malaysia">Telekom Malaysia</option>
<option value="Maxis">Maxis</option>
<option value="Celcom">Celcom</option>
</select>
<select id="filter-type">
<option value="">All Types</option>
<option value="fiber">Fiber</option>
<option value="wireless">Wireless</option>
</select>
<input type="range" id="filter-max-price"
min="50" max="500" value="500"
oninput="updatePriceLabel(this.value)" />
<span id="price-label">Up to MYR 500</span>
<select id="sort-by">
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="speed-desc">Speed: Fastest First</option>
</select>
</div>
<div id="results"></div>
Attach a single event listener to the whole filter container instead of one per input — this is cleaner and handles dynamically added filters too:
document.querySelector(".filters").addEventListener("change", renderResults);
document.getElementById("filter-max-price").addEventListener("input", renderResults);
3. Filter and Sort Logic
This is the core of the tool — a pure function that takes the full dataset and returns a filtered, sorted subset:
function getFilteredPlans() {
const provider = document.getElementById("filter-provider").value;
const type = document.getElementById("filter-type").value;
const maxPrice = parseInt(document.getElementById("filter-max-price").value);
const sortBy = document.getElementById("sort-by").value;
let result = plans.filter(plan => {
if (provider && plan.provider !== provider) return false;
if (type && plan.type !== type) return false;
if (plan.price > maxPrice) return false;
return true;
});
result.sort((a, b) => {
switch (sortBy) {
case "price-asc": return a.price - b.price;
case "price-desc": return b.price - a.price;
case "speed-desc": return b.speed - a.speed;
default: return 0;
}
});
return result;
}
Keep this function pure — no side effects, just filtering and sorting. It is easy to test and reason about.
4. Render Cards
Once you have a filtered array, render it to the DOM. The simplest approach is to replace innerHTML entirely on each render call. For datasets under a few hundred items, this is fast enough and avoids diffing complexity:
function renderResults() {
const plans = getFilteredPlans();
const container = document.getElementById("results");
if (plans.length === 0) {
container.innerHTML = `<p class="empty">No plans match your filters.</p>`;
return;
}
container.innerHTML = plans.map(plan => `
<div class="card">
<div class="card-header">
<h3>${plan.name}</h3>
<span class="provider">${plan.provider}</span>
</div>
<div class="card-body">
<div class="stat">
<span class="label">Speed</span>
<span class="value">${plan.speed} Mbps</span>
</div>
<div class="stat">
<span class="label">Price</span>
<span class="value">MYR ${plan.price}/mo</span>
</div>
<div class="stat">
<span class="label">Contract</span>
<span class="value">${plan.contract} months</span>
</div>
</div>
<div class="card-tag">${plan.type}</div>
</div>
`).join("");
}
// Run on load
renderResults();
Using template literals for card HTML keeps things readable without a templating library.
5. Progressive Enhancement Tips
Once the basic pattern works, a few small additions make a big difference:
Show a result count — Users like knowing how many results are visible:
document.getElementById("result-count").textContent =
`Showing ${plans.length} plan${plans.length !== 1 ? "s" : ""}`;
Persist filters to URL params — So users can share filtered views:
function syncToUrl() {
const params = new URLSearchParams();
const provider = document.getElementById("filter-provider").value;
if (provider) params.set("provider", provider);
history.replaceState(null, "", "?" + params.toString());
}
Read filters from URL on load — The other side of the above:
function loadFromUrl() {
const params = new URLSearchParams(location.search);
if (params.get("provider")) {
document.getElementById("filter-provider").value = params.get("provider");
}
}
loadFromUrl();
renderResults();
Highlight the best value — Add a best-value class to the first result after sorting by price/speed ratio, for example.
6. Styling the Cards
A minimal CSS grid makes cards responsive without media queries:
#results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
background: white;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.stat {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid #f1f5f9;
}
auto-fill with minmax means you get 1 column on mobile, 2-3 on tablet, and 4+ on desktop — no breakpoints needed.
Why Vanilla JS Works Here
This pattern does not need a framework because:
- The data is static — no server, no subscriptions, no async complexity
- The UI state is simple — just a few input values driving one render call
- Performance is not a bottleneck — re-rendering 50 cards on each keypress is imperceptible
- No build step — deploy as a single HTML file or push to GitHub Pages
If your dataset grows to thousands of items or you need complex shared state, then a framework starts to earn its keep. But for a comparison tool with <500 items? Vanilla JS is the right tool.
See It in Action
I built this exact pattern for two live tools covering the Malaysian market:
- Broadband Comparison — Compare fiber and wireless plans by speed, price, provider, and contract length
- Mobile Plan Comparison — Filter prepaid and postpaid plans by data, calls, and monthly cost
Both are static pages — no backend, no build step, just HTML/CSS/JS hosted on GitHub Pages. The full filter-sort-render loop described above is exactly what powers them.
If you find this pattern useful, try it for your next comparison, directory, or catalog project. You might be surprised how far plain JavaScript takes you.
Top comments (0)