Skip to content
Development

The aria-label i18n bug that was hiding in plain sight

By Victor Da Luz
astroi18naccessibilitydev-logastro-tools

@vdaluz/astro-blog has shipped an en/es i18n layer for a while now. Every visible string in PostCard, Pagination, BlogPostMeta goes through a t(locale) lookup. I was fairly proud of that when I built it. So it was deflating to find two aria-labels that never got the memo.

Where they were hiding

Pagination.astro’s individual arrow buttons - first, prev, next, last - all correctly call strings.goToFirstPage and friends. But the outer <nav> wrapping the whole pagination block had:

<nav class="..." aria-label="Blog pagination">

A bare string literal, sitting one line above code already doing the right thing three separate times over. TagFilterNav.astro had the same shape: a locale prop was never even part of its interface, so its default aria-label was just 'Filter posts', full stop.

On an English page nobody notices. On a Spanish blog index, a screen reader announces “Blog pagination” in English on an otherwise Spanish page. Small, but exactly the kind of small that erodes trust in a translation.

Why partial correctness is better camouflage

The thing that got me was how plausible the bug looked. Grep Pagination.astro for aria-label and you see four correct call sites in a row. The fifth one, the nav wrapper, blends right in unless you’re specifically checking each against the i18n file.

A component that’s 100% hardcoded gets caught the first time someone loads the Spanish site. A component that’s 90% correct passes a skim review and survives until someone reads every line against the source of truth - the same reason a subheading prop lying about its color went unnoticed across six call sites.

The fix

Two new strings in i18n.ts (blogPagination and filterPosts, with their Spanish counterparts). Pagination.astro’s nav now reads aria-label={strings.blogPagination}. TagFilterNav.astro got an actual locale prop defaulting to 'en', with the default aria-label falling back to strings.filterPosts - but only when the consumer doesn’t pass an explicit ariaLabel override, since some sites already translate that label themselves at the app level.

Verifying it without a build step

astro-blog ships raw source with no build step, so I could point directly at the bug: overlay the local fix into a consuming site’s installed copy, run that site’s dev server, load the real /es/blog page in a browser. The nav’s accessibility-tree label read “Paginación del blog.” Not a snapshot test, the actual rendered page.

That also surfaced something useful. The site I tested against already passes its own explicit ariaLabel override to TagFilterNav, so my new default fallback never fires on that page. Which is correct, the override is supposed to win, but it means I only proved the override path still works, not the new default. The default string is covered by a unit test, not a live page. Worth being honest about the difference between those two kinds of verified, rather than letting a passing check stand in for the wrong claim.

What I’d check next time

Before shipping an i18n layer, grep every literal string prop across every component and diff it against the strings file, not just the ones that already look like they’re using t(). A partially-wired component is a worse bug than a fully hardcoded one, because it’s harder to spot.

Related reading