The Tour Category Filter
The category filter lets visitors narrow the tour list by type: All, Hiking, Walking. Clicking a button shows only the matching cards and hides the rest. The active button gets a visual highlight.
This feature combines three things you have already learned: data-* attributes from Module 05, event delegation from Module 06, and DOM iteration from Module 03.
What it does when complete
Section titled “What it does when complete”- Three filter buttons: All, Hiking, Walking
- Clicking a button shows only tours with a matching
data-category - “All” shows every tour card
- The active button has an
activeclass for styling
Step 1 — HTML prerequisites
Section titled “Step 1 — HTML prerequisites”Tour cards already have data-category set — renderTourCard() in main.js writes it to the <article> element from the tour data object (added in Module 05). No manual HTML edit needed for the cards.
Add the filter buttons before the .tours-grid in both index.html and tours.html. In index.html they go before the .tours-grid inside .featured-tours; in tours.html they go before the first .tours-grid inside .tours-page-content:
<div class="filter-buttons"> <button class="filter-btn active" data-category="all">All</button> <button class="filter-btn" data-category="hiking">Hiking</button> <button class="filter-btn" data-category="walking">Walking</button></div>The hardcoded tour cards in both pages need data-category on their <article> element. All hardcoded STO tours are hiking tours:
<article class="tour-card" data-category="hiking">The 'all' value is the special case that shows everything. The other values must match exactly the category values in your tour data ('hiking', 'walking').
Add the following to styles.css in the featured tours section:
.filter-buttons { display: flex; gap: var(--space-sm); margin-bottom: var(--space-md); flex-wrap: wrap;}
.filter-btn { padding: var(--space-xs) var(--space-md); border: 2px solid var(--color-primary); background: none; color: var(--color-primary); border-radius: 4px; cursor: pointer; font-size: 0.9rem; font-weight: 600; transition: background-color 0.2s, color 0.2s;}
.filter-btn:hover,.filter-btn.active { background-color: var(--color-primary); color: var(--color-white);}Step 2 — Select elements
Section titled “Step 2 — Select elements”const filterContainer = document.querySelector('.filter-buttons');Note: do not select .tour-card elements at module scope here — the cards are rendered dynamically by the loop above, so a top-level querySelectorAll would run before they exist. The filterTours function selects them fresh on each call instead.
Step 3 — Wire the delegation listener
Section titled “Step 3 — Wire the delegation listener”One listener on the button container, closest('[data-category]') to identify the clicked button:
if (filterContainer) { filterContainer.addEventListener('click', (event) => { const btn = event.target.closest('[data-category]'); if (!btn) return;
// Update active class on buttons filterContainer.querySelectorAll('.filter-btn').forEach(b => { b.classList.remove('active'); }); btn.classList.add('active');
filterTours(btn.dataset.category); });}Step 4 — Filter logic
Section titled “Step 4 — Filter logic”Show all cards if category is 'all', otherwise match against each card’s data-category. Query .tour-card elements inside the function so they are always selected after the render loop has run:
function filterTours(category) { const tourCards = document.querySelectorAll('.tour-card'); tourCards.forEach(card => { if (category === 'all') { card.style.display = ''; } else { const matches = card.dataset.category === category; card.style.display = matches ? '' : 'none'; } });}Setting display to '' (empty string) removes the inline style, restoring whatever the CSS sets. Setting it to 'none' hides the card.
Complete feature code
Section titled “Complete feature code”const filterContainer = document.querySelector('.filter-buttons');
function filterTours(category) { const tourCards = document.querySelectorAll('.tour-card'); tourCards.forEach(card => { if (category === 'all') { card.style.display = ''; } else { card.style.display = card.dataset.category === category ? '' : 'none'; } });}
if (filterContainer) { filterContainer.addEventListener('click', (event) => { const btn = event.target.closest('[data-category]'); if (!btn) return;
filterContainer.querySelectorAll('.filter-btn').forEach(b => { b.classList.remove('active'); }); btn.classList.add('active');
filterTours(btn.dataset.category); });}Step 5 — Verify
Section titled “Step 5 — Verify”- Page loads — All button is active, all cards visible
- Click Hiking — only hiking cards show
- Click Walking — only the Valley Floor Walk shows
- Click All — all cards return
- The active button class moves correctly between buttons
- Filter buttons use
data-categoryon the button; tour cards have it set byrenderTourCard()— the common key links them. - Event delegation on the container means zero listeners on individual buttons — new buttons added later need no re-wiring.
filterTours('all')removes inlinedisplaystyles;'none'hides non-matching cards.btn.dataset.category === card.dataset.categoryis the entire matching logic — one comparison per card.