Tag pills turned an already-collected field into working search
Shipped
My dev log had grown to about a hundred entries across several projects with no way to find one except scrolling or a coarse project filter, so I added live tag search: type a word or click a tag pill and the visible list filters instantly, no page reload. I almost built this as full-text search, then found I already had better raw material sitting unused: every post’s frontmatter already carries 5-10 topic tags, generated when the post itself was written. The actual work was wiring that existing field through to the page and building a search UI on top of it, not inventing a new pipeline. That pivot is the first lesson; the accessible-card-markup problem it ran into is the second, bigger one.
Setting up: a field that already existed, just not where the page could see it
The listing page only fetches one JSON file per project at build time — a manifest with date, file, title, summary, and a version. Tags lived only in each post’s own Markdown frontmatter, one level deeper than the listing page ever reads. So the fix wasn’t a new feature, it was plumbing: add a tags array to the manifest entry, and teach the site’s own minimal frontmatter parser to actually parse tags: [a, b, c] instead of capturing it as the literal string "[a, b, c]".
{
"date": "2026-07-15",
"file": "v0.6.0.md",
"title": "...",
"summary": "...",
"version": "v0.6.0",
"tags": ["search", "accessibility", "aria"]
}
// Bracket-parsing scoped specifically to the `tags` key — every other
// frontmatter field keeps the existing plain-string capture unmodified.
function parseTagsValue(value) {
const trimmed = value.trim();
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
const inner = trimmed.slice(1, -1).trim();
return inner === '' ? [] : inner.split(',').map((t) => t.trim()).filter(Boolean);
}
Any manifest entry or local draft with no tags field at all has to become [], not a crash — tags.filter(...) on undefined throws, and a field that’s been present since before this change shouldn’t suddenly break every entry that predates it.
function normalizeTags(rawTags) {
return Array.isArray(rawTags)
? rawTags.filter((t) => typeof t === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(t))
: [];
}
That character check matters for more than tidiness: the search script joins a post’s tags into one string with | as the delimiter, so no tag is ever allowed to contain a | itself, or it could be misread as a boundary between two tags that don’t actually match.
Building it: pills are their own controls, not text stapled inside a link
The natural place to put a clickable tag pill looked like inside the existing card, which was a single anchor wrapping the kicker, title, and summary. That’s exactly the setup Inclusive Components’ piece on card patterns warns against: nesting another interactive element inside an anchor means some screen readers only announce the outer link and skip the nested control entirely, on top of <button> inside <a> being invalid HTML in the first place. My first draft relied on stopPropagation() in the pill’s click handler to stop clicks from bubbling into navigation — which quietly assumed JavaScript was already running, so a pill with no listener attached would just navigate on click instead of doing nothing.
The fix is to never put the pills inside the navigating link at all:
<div class="entry" data-tags="search|accessibility|aria">
<a class="entry__link" href="/devlog/personal/v0.6.0/">
<span class="entry__title">Tag pills turned an already-collected field into working search</span>
<span class="entry__summary">...</span>
</a>
<div class="entry__tags" role="group" aria-label="Tags">
<button type="button" class="tag-pill" data-tag="search" tabindex="0">search</button>
<button type="button" class="tag-pill" data-tag="accessibility" tabindex="-1">accessibility</button>
<button type="button" class="tag-pill" data-tag="aria" tabindex="-1">aria</button>
</div>
</div>
The pills row is a sibling of the link, not a descendant of it. That makes the card semantically honest — a real link and a real, independent group of buttons — but it also means the link on its own only covers its own text, a much smaller click target than the full padded row people are used to clicking. The inclusive-components pattern fixes that with a stretched link: give the card container position: relative, then give the link’s own ::after pseudo-element position: absolute; inset: 0; so it visually covers the whole card while the actual <a> stays scoped to just its text semantically.
.entry { position: relative; }
.entry__link { display: block; }
.entry__link::after { content: ''; position: absolute; inset: 0; z-index: 1; }
.entry__tags { position: relative; z-index: 2; } /* sit above the stretched link */
That z-index on the tags row is what keeps the pills clickable at all — without it, the stretched ::after sits on top of everything, including the buttons, and every “click” on a pill actually lands on the invisible full-card link underneath it.
Building it: one Tab stop per card, however many pills it has
Raising a post’s tag count meant a single card could carry up to ten pills. If each pill were an ordinary tab stop, one card would cost ten Tab presses to get past instead of one, for every card in a feed of a hundred-plus entries — a real, cumulative keyboard cost. The WAI-ARIA Authoring Practices Guide’s keyboard interface page describes the standard answer for exactly this shape of problem, a composite widget where many related controls should cost one Tab stop: roving tabindex. Only one element in the group carries tabindex="0" — the one Tab lands on — and every other element in that group carries tabindex="-1", focusable once you’re inside the group but invisible to the page’s outer Tab order.
function wireRovingTabindex(group) {
const pills = [...group.querySelectorAll('.tag-pill')];
group.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const current = pills.findIndex((p) => p.tabIndex === 0);
const next = e.key === 'ArrowRight'
? (current + 1) % pills.length
: (current - 1 + pills.length) % pills.length;
pills[current].tabIndex = -1;
pills[next].tabIndex = 0;
pills[next].focus();
});
}
Tabbing onto the group always lands on whichever pill currently holds tabindex="0"; arrow keys move that flag (and focus) among the pills without ever changing the page’s overall Tab order. Every pill stays fully reachable once you’ve tabbed in — the change is how you enter and move through the group, not which pills work.
Using it: two match modes, because a shared one has a real false-positive
The tempting shortcut was to make clicking a pill just fill in the search box and re-run the same text filter typing uses. That breaks the moment two tags share a substring: clicking a pill tagged ai would also match any post tagged ai-agents, since ai-agents contains ai. Typing and pill-clicking are two different match rules against the same data-tags string:
function matchesTyped(dataTags, query) {
// Broad on purpose — this is what makes partial words match as you type.
return dataTags.toLowerCase().includes(query.toLowerCase());
}
function matchesPillClick(dataTags, clickedTag) {
// Exact — split the joined string back into real tags and compare directly,
// so clicking "ai" can never also surface a post whose only tag is "ai-agents".
return dataTags.split('|').includes(clickedTag);
}
Wiring both into the page means every card gets a lightweight data-tags attribute checked on every keystroke and every pill click, plus a live region announcing how many cards are currently visible so a screen reader user isn’t left guessing whether their query matched anything:
let announceTimer = null;
function onSearchInput(query, cards, liveRegion) {
const visible = cards.filter((c) => {
const hide = query && !matchesTyped(c.dataset.tags, query);
c.classList.toggle('is-search-hidden', hide);
return !hide;
});
// Debounce only the screen-reader announcement, not the visible filtering —
// filtering every keystroke is fine at this corpus size, but re-announcing
// "N results" on every keystroke talks over the user's own typing.
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
liveRegion.textContent = `${visible.length} result${visible.length === 1 ? '' : 's'}`;
}, 300);
}
The 300ms figure isn’t arbitrary — it’s the standard interval for this exact pattern, per MDN’s debounce glossary entry: consolidate a burst of rapid calls into one, firing after activity actually stops rather than on every single event. Type a query, watch the count settle a beat after you stop typing, and the visible cards should already have narrowed to just the matches — instantly, with no debounce, since that part isn’t the thing being throttled.
Gotchas
- A
stopPropagation()call papers over the real problem instead of fixing it. My first draft relied on the pill’s click handler calling it to keep clicks from bubbling into the card’s navigation link. That’s backwards: it only works once JavaScript has already loaded, so a pill with no listener attached yet still navigates on click. Restructuring the markup so pills are never inside the anchor at all makes “does nothing without JS” true structurally, with nothing left to rely on. - The stretched-link pseudo-element will happily eat every other click in the card. The moment
::afterstretches across the whole card, it sits on top of anything else in there unless you explicitly give that other thing a higherz-indexandposition: relative. I lost an afternoon to “clicking pills does nothing” before realizing the invisible overlay link was winning every hit test. - A CSS-only dedup rule can go stale the moment JS starts hiding things too. My feed already had a pure-CSS rule to hide a duplicate card on the “all posts” view. Wiring in JS-driven search on top of it meant that old rule kept firing even while a search query was active, hiding a card that should have reappeared as soon as it matched. It needed its own
.is-searchingclass as an explicit off switch, checked before the original CSS rule was allowed to apply.
Sources
- WAI-ARIA Authoring Practices Guide: Keyboard Interface — defines the roving-tabindex pattern: one element in a group at
tabindex="0", the rest attabindex="-1", with arrow keys moving the flag and focus between them. - Inclusive Components: Cards — the nested-link accessibility problem in card components, and the pseudo-element stretched-link fix that keeps the rest of the card independently operable.
- MDN Web Docs: Debounce — debouncing consolidates a burst of rapid calls into one, firing on the trailing edge once activity has actually stopped; the standard technique for a search-as-you-type announcement.