Sanity dictionary for aria-label and alt

Visible copy came from Sanity and next-intl. aria-label stayed English. Wire a11y strings through the same dictionary with fallback.

Sander Korf2 min read
sanitynext-intla11ynextjs

Next.js Shopify storefront. Visible UI strings already flowed through Sanity Global Terms and next-intl. Screen-reader strings did not. aria-label, image alt, and carousel pause/play stayed hardcoded English. A Dutch shopper heard fine copy on screen, then English when VoiceOver hit the clear control.

That mismatch is easy to miss in a PR review. You glance at the button. The glyph looks fine. You never tab with a screen reader. Non-EN locales sounded polished until assistive tech spoke the leftover English.

I added accessibility fields on the Sanity globalTerms document and seeded EN defaults. Every control now goes through translate(key, { fallback, ...vars }). The hook returns an object, { translate, translateDynamic }, so callers destructure instead of treating the hook as a bare function. That keeps dynamic keys and static keys on the same lookup path without two competing APIs.

Shared resolveTranslation(lookup, term, options) is the boring part that saves you. If the dictionary has the key, pass ICU values through. If it does not, return the fallback and manually replace {name} placeholders from options. Reserved keys stay out of interpolation: fallback, defaultMessage, locale. UI primitives keep EN aria defaults for local storybook, but they accept override props like pauseLabel and playLabel so production can inject the dictionary string.

aria-label from dictionary, not a hardcoded string

Toggle the source. Visible copy already says Dutch. The screen-reader string should follow, with {label} interpolation and an EN fallback.

Clear control (icon only)

aria-label="Clear recently viewed"

EN hardcodedvisible: recentelijk bekeken
export const resolveTranslation = (lookup, term, options?) => {
	if (lookup.has(term)) {
		const values = Object.fromEntries(
			Object.entries(options ?? {}).filter(([k, v]) => !RESERVED.has(k) && v !== undefined),
		)
		return Object.keys(values).length ? lookup.get(term, values) : lookup.get(term)
	}
	let fallback = options?.fallback ?? options?.defaultMessage ?? ''
	// replace {key} in fallback from options
	return fallback
}
 
// usage
aria-label={translate('clearRecentlyViewed', {
	fallback: `Clear ${recentlyViewedText}`,
	label: recentlyViewedText,
})}

If the visible label is localized and the aria string is not, you shipped half a translation. Wire both through the same dictionary or admit the control is English-only.


Happy coding! Sander