Nuxt Accessibility Checklist 2026 | WCAG 2.1 AA & EAA Compliance
Last updated: 2026-04-21
Nuxt is the dominant meta-framework for Vue.js, and its universal rendering model, file-based routing, and auto-import conveniences make it a popular choice for ecommerce storefronts, SaaS marketing sites, content portals, and progressive web apps across Europe and Asia. Because Nuxt sites hydrate Vue components on top of server-rendered HTML, accessibility problems tend to appear on two fronts simultaneously: the server-rendered snapshot that screen readers see first, and the hydrated interactive experience that keyboard and assistive tech users interact with once JavaScript runs. A misconfigured
Common Accessibility Issues
Nuxt's <NuxtPage /> component handles client-side navigation through the vue-router integration. By default, navigating between routes does not update focus, does not announce the new page to screen readers, and often leaves the page title lagging behind the URL change. Screen reader users have no audible feedback that they arrived at a new page.
Add a route announcer to your app.vue layout. Listen for router.afterEach, update document.title from route meta, and push the new title into an aria-live="assertive" region. Move focus to the main element or the new h1 on route change, and make sure the main element has tabindex="-1" so programmatic focus is possible.
<!-- app.vue -->
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template> <!-- app.vue -->
<script setup>
const router = useRouter();
const announce = ref('');
router.afterEach((to) => {
nextTick(() => {
const title = to.meta.title ?? document.title;
announce.value = title;
document.getElementById('main')?.focus();
});
});
</script>
<template>
<div>
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announce }}</div>
<NuxtLayout>
<main id="main" tabindex="-1"><NuxtPage /></main>
</NuxtLayout>
</div>
</template> Projects using @nuxtjs/i18n or manual locale detection commonly set the page title through useSeoMeta but forget to update the html element lang attribute when locale changes. Screen readers continue announcing content in the initial locale language regardless of the actual page content.
Use useHead({ htmlAttrs: { lang: locale.value, dir: ['ar','he','fa'].includes(locale.value) ? 'rtl' : 'ltr' } }) inside a composable that watches the current locale. Call this composable from every layout so the html element always reflects the active language. When @nuxtjs/i18n is installed, wire it through the head() returning from defineI18nLocale or the default i18n head helper.
<!-- layouts/default.vue -->
<script setup>
useSeoMeta({ title: 'My Site' });
</script> <!-- layouts/default.vue -->
<script setup>
const { locale } = useI18n();
useHead({
htmlAttrs: {
lang: computed(() => locale.value),
dir: computed(() => ['ar','he','fa'].includes(locale.value) ? 'rtl' : 'ltr'),
},
});
useSeoMeta({ title: 'My Site' });
</script> Developers wrap widgets in <ClientOnly> to avoid hydration mismatches, but sometimes wrap content that matters for accessibility: the navigation, the hero heading, the main call to action. Screen readers that execute server HTML before JavaScript see a page with no navigation and no main content, and crawlers index an empty shell.
Use <ClientOnly> only for components that genuinely cannot server-render (date formatters relying on the user locale, components using window APIs). Provide a meaningful <template #fallback> slot with server-rendered HTML that conveys the same information in static form. Prefer fixing hydration mismatches at the source rather than hiding them with ClientOnly.
<ClientOnly>
<MainNavigation :items="navItems" />
</ClientOnly> <!-- Render navigation on the server so screen readers and crawlers see it. -->
<MainNavigation :items="navItems" />
<!-- Only wrap genuinely client-dependent widgets in ClientOnly: -->
<ClientOnly>
<LocaleFormattedDate :value="publishedAt" />
<template #fallback>
<time :datetime="publishedAt">{{ publishedAt }}</time>
</template>
</ClientOnly> Nuxt UI provides <UModal> and <USlideover> components that are trapped focus by default, but developers frequently override the focus-trap prop or bypass it with custom markup. Modals built without a focus trap let tab key navigation escape to the page underneath, breaking screen reader and keyboard user expectations.
Keep the default focus-trap and overlay props enabled on <UModal> and <USlideover>. When you build a custom dialog, use the @vueuse/core useFocusTrap composable, store the previously focused element on open, and return focus to it on close. Ensure the first focusable element inside the modal receives focus on open, and that Escape closes the dialog.
<UModal v-model="open" :focus-trap="false">
<!-- Custom content without focus management -->
<button @click="open = false">Close</button>
</UModal> <UModal v-model="open">
<div role="document" aria-labelledby="modal-title">
<h2 id="modal-title" tabindex="-1" ref="firstFocus">Confirm action</h2>
<p>Are you sure?</p>
<UButton @click="open = false">Cancel</UButton>
<UButton color="primary" @click="confirm">Confirm</UButton>
</div>
</UModal>
<script setup>
const open = ref(false);
const firstFocus = ref(null);
watch(open, async (v) => { if (v) { await nextTick(); firstFocus.value?.focus(); } });
</script> Page and element transitions that animate opacity from 0 to 1 over several hundred milliseconds sometimes leave the animating element with visibility:hidden or aria-hidden="true" applied for the duration of the transition, or for the full time a route is loading. Screen reader users hear nothing during that window, which is confusing on a slow network.
Keep transition durations short (100-300ms) and verify the transitioning element does not receive aria-hidden during animation. For page transitions, show a visible and announced loading indicator with role="status" that reads "Loading" while the next route resolves. Respect prefers-reduced-motion by skipping the transition entirely when users have requested reduced motion.
Nuxt server routes in server/api return HTML fragments that get inserted into pages. These fragments sometimes contain prose content in a different language than the surrounding page, but no lang attribute, or they return full HTML documents without the Content-Language header when the endpoint is used as a download or oEmbed response.
In server API handlers that produce HTML, wrap prose in a <div lang="..."> when the language differs from the page, and set the Content-Language header explicitly via setHeader(event, 'Content-Language', locale). Treat every HTML payload as a first-class accessibility surface, not just the Vue-rendered views.
The @nuxt/image module provides <NuxtImg /> with automatic optimization, but developers often omit the sizes, width, and height props. Omitting width and height causes cumulative layout shift that effectively fails WCAG 1.4.10 Reflow for users zooming on mobile, and omitting sizes causes the browser to pick the largest variant even on small screens.
Always pass explicit width and height props to <NuxtImg /> (or <NuxtPicture /> for art-directed images). Set sizes with the same breakpoints as your CSS layout. Use loading="lazy" below the fold but never on above-the-fold hero images, which should load eagerly for LCP and for users who depend on fast content reading order.
<NuxtImg src="/hero.jpg" alt="Team photo" /> <NuxtImg
src="/hero.jpg"
alt="Team photo"
width="1440"
height="810"
sizes="sm:100vw md:100vw lg:1440px"
:loading="isHero ? 'eager' : 'lazy'"
format="webp"
/> Nuxt-Specific Tips
- Install the @nuxt/eslint module and enable the vuejs-accessibility ESLint plugin. It catches missing alt text, invalid ARIA, and keyboard-inaccessible event handlers in your .vue files at build time.
- Use defineNuxtRouteMiddleware to set route-level meta (title, description, locale, restrict) so the announcer in app.vue can read a canonical page title rather than scraping the h1 after render.
- When using Nuxt UI, enable the color-mode integration and verify that both light and dark palettes pass 4.5:1 contrast for body text using your design token values. The default Nuxt UI tokens pass, but custom tokens frequently do not.
- Ship a prefers-reduced-motion aware CSS block in your main stylesheet that disables Vue transition CSS for users who request reduced motion. Query window.matchMedia("(prefers-reduced-motion: reduce)") on the client to also skip JavaScript-driven animation in composables like useMouse.
- Integrate Pa11y or axe-core/playwright into your CI pipeline, pointed at the nuxt preview output. Run it against a representative route set (home, product page, blog post, form page, dashboard) to catch regressions pre-merge.
Recommended Tools
vuejs-accessibility ESLint plugin
Static analysis for Vue single-file components, flagging missing alt text, invalid ARIA, form controls without labels, and keyboard-inaccessible components. Integrates with the @nuxt/eslint module for one-command setup in Nuxt 3 and Nuxt 4 projects.
Nuxt UI
Nuxt's official component library. Components like UModal, USelectMenu, UCommandPalette, and UPopover ship with keyboard navigation and ARIA attributes built-in. Keep the focus-trap defaults enabled and pass accessible labels via the aria-label prop for icon-only triggers.
axe-core/playwright
Automated accessibility testing for your Nuxt preview build. Run axe against every route in CI and fail the pipeline on new critical violations. Pairs well with Playwright, which supports both the Nuxt dev server and static builds.
Further Reading
Other CMS Checklists
Get our free accessibility toolkit
We're building a simple accessibility checker for non-developers. Join the waitlist for early access and a free EAA compliance checklist.
No spam. Unsubscribe anytime.