The home page of this site is a server-rendered list of article cards. Without JavaScript, it is still a complete, linkable page. With JavaScript, it gains instant search, category filters, and a reading list. This article walks through how that enhancement works and why it is built this way.
Start with HTML that already works
Each card is an ordinary article element with a real link. The extra data the script needs travels in data attributes:
article.post-card data-category="{{post.category}}" data-slug="{{post.slug}}" a.media href="/post/{{post.slug}}" h3 a href="/post/{{post.slug}}" "{{post.title}}"
If the script fails to load, readers still see every story and can open each one. Enhancement adds convenience. It never takes away access.
Build filters from the content
Instead of hard-coding category buttons, the script reads them from the cards. A new category in the CMS gets a filter tab automatically:
const cards = [...document.querySelectorAll('.post-card')]; for (const name of new Set(cards.map(card => card.dataset.category).filter(Boolean))) { const button = document.createElement('button'); button.type = 'button'; button.className = 'filter-tab'; button.dataset.filter = name; button.textContent = name; tabs.append(button); }
One function decides visibility
Search, category, and the saved-only toggle are combined in a single update function. A card is visible only when it passes all three tests. Keeping the rule in one place avoids the classic bug where two filters fight each other.
card.hidden = !( (category === 'all' || card.dataset.category === category) && (!onlySaved || saved.has(card.dataset.slug)) && card.textContent.toLowerCase().includes(query) );
Using the hidden attribute removes cards from both the layout and the accessibility tree, so screen reader users hear the same filtered list that sighted users see.
Tell people what happened
Filtering should never leave a mystery. The script updates a status line such as "03 stories to explore". When nothing matches, it shows an empty state that explains why, instead of a blank grid. Buttons also expose their state with aria-pressed, so assistive technology announces which filter is active.
Bookmarks with localStorage, done defensively
Saved articles are stored as a list of slugs in localStorage. Storage can be disabled, full, or corrupted, so every read and write is wrapped:
const read = (key, fallback) => { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }; const write = (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* Storage may be disabled. */ } };
The stored value is also validated, so only strings are kept, before it becomes a Set. Treat anything read from storage as untrusted input, even if your own code wrote it.
localStorage belongs to one browser on one device. A reading list built this way does not sync between devices and disappears when site data is cleared. That is fine for a convenience feature, but not for anything a reader would be upset to lose.
Checklist for your own feed
- The page works and every link opens with JavaScript disabled.
- Filters are derived from the content, not duplicated in the script.
- A single function combines all filters.
- The status text and empty state explain the result.
- Button state is exposed with aria-pressed.
- Storage access is wrapped in try/catch and validated.
Comments