
Web accessibility (abbreviated as a11y) is the practice of creating websites that everyone can use. People with visual, auditory, motor, or cognitive impairments should have the same experience as other users.
In this guide, we'll explore how to implement accessibility in Nuxt applications: from basic principles to specific tools.
Accessibility isn't just about people with disabilities. It also covers:
Fact: According to WebAIM, among the top one million websites, Vue.js applications have traditionally had the worst accessibility scores among JavaScript frameworks. This is what prompted the community to create the Vue A11y project.
| Reason | Description |
|---|---|
| Ethical | Everyone deserves equal access to information |
| Legal | Many countries have accessibility laws (ADA, EAA, Section 508) |
| Business | 15% of the world's population has some form of disability |
| SEO | Search engines better index accessible sites |
WCAG (Web Content Accessibility Guidelines) is the international accessibility standard from the W3C. The current version is WCAG 2.2.
| Level | Description | Requirements |
|---|---|---|
| A | Minimal | Basic barriers removed |
| AA | Recommended | The standard for most laws |
| AAA | Highest | Not always achievable for all content |
Recommendation: Aim for Level AA—it's required by most laws and covers the essential needs of users.
Nuxt has several built-in mechanisms that make it easier to create accessible applications.
SSR provides a complete markup on the initial load. Screen readers get all the content without waiting for JavaScript.
Nuxt 3 automatically announces route changes for assistive technologies:
<!-- app.vue -->
<template>
<NuxtRouteAnnouncer />
<NuxtPage />
</template>
The component creates a hidden element with aria-live="polite" that informs screen readers about the new page.
During navigation, Nuxt updates document.title, which is also announced by screen readers.
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
titleTemplate: '%s | My Site'
}
}
})
Semantic HTML is the foundation of accessibility. Use the correct elements instead of <div> and <span>.
<template>
<main>
<h1>Main Page Heading</h1>
<section>
<h2>Section</h2>
<h3>Subsection</h3>
</section>
<aside>
<h2>Sidebar</h2>
</aside>
</main>
</template>
Important: Do not skip heading levels. An
<h2>should follow an<h1>, not an<h3>.
Screen readers use landmarks for quick navigation:
<template>
<header role="banner">
<nav aria-label="Main Navigation">
<!-- menu -->
</nav>
</header>
<main role="main">
<!-- main content -->
</main>
<aside role="complementary">
<!-- supplementary content -->
</aside>
<footer role="contentinfo">
<!-- footer -->
</footer>
</template>
ARIA (Accessible Rich Internet Applications) supplements semantics where HTML is insufficient:
<template>
<!-- Toggle button -->
<button
:aria-expanded="isOpen"
:aria-controls="panelId"
@click="toggle"
>
Show Details
</button>
<div
:id="panelId"
v-show="isOpen"
role="region"
aria-labelledby="toggle-btn"
>
Panel content
</div>
</template>
Key ARIA attributes:
| Attribute | Purpose |
|---|---|
aria-label | Text label for an element |
aria-labelledby | Reference to an element with a label |
aria-describedby | Reference to a description of the element |
aria-expanded | State of an expanded/collapsed element |
aria-hidden | Hides an element from assistive technologies |
aria-live | Announces dynamic changes |
Rule: Do not use ARIA to "fix" incorrect HTML markup. Fix the semantics first.
Every interactive element must be accessible via the keyboard.
| Key | Action |
|---|---|
| Tab | Move to the next element |
| Shift + Tab | Move to the previous element |
| Enter / Space | Activate the element |
| Escape | Close modals, menus |
| Arrows | Navigate within components |
<script setup lang="ts">
const headingRef = ref<HTMLElement | null>(null)
// Focus on the heading after navigation
onMounted(() => {
headingRef.value?.focus()
})
</script>
<template>
<h1 ref="headingRef" tabindex="-1">
Page Title
</h1>
</template>
tabindex="-1" allows programmatic focus on an element but excludes it from the natural tab order.
Allows users to bypass repetitive navigation:
<template>
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:p-4"
>
Skip to main content
</a>
<header>
<!-- navigation -->
</header>
<main id="main-content">
<!-- content -->
</main>
</template>
<script setup lang="ts">
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
const modalRef = ref<HTMLElement | null>(null)
const { activate, deactivate } = useFocusTrap(modalRef)
watch(() => props.open, (isOpen) => {
if (isOpen) {
activate()
} else {
deactivate()
}
})
</script>
<template>
<div
v-if="open"
ref="modalRef"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">Modal Title</h2>
<!-- content -->
<button @click="close">Close</button>
</div>
</template>
SPAs have a problem: on navigation, the page doesn't reload, and the screen reader doesn't know that something has changed.
Nuxt 3 solves this automatically:
<!-- app.vue -->
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
The component announces the new page's title with aria-live="polite".
For dynamic changes (adding to cart, form errors), use live regions:
<script setup lang="ts">
const announcement = ref('')
function addToCart(product: string) {
// business logic
announcement.value = `${product} added to cart`
// clear after 5 seconds
setTimeout(() => {
announcement.value = ''
}, 5000)
}
</script>
<template>
<!-- Hidden container for announcements -->
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ announcement }}
</div>
<button @click="addToCart('Product')">
Add to Cart
</button>
</template>
| Value | Behavior |
|---|---|
polite | Waits for the current announcement to finish |
assertive | Interrupts the current announcement (for errors) |
off | Disabled |
Tip: Use
assertiveonly for critical messages. Frequent interruptions can be annoying to users.
| Text Size | Level AA | Level AAA |
|---|---|---|
| Normal (< 18px) | 4.5:1 | 7:1 |
| Large (≥ 18px or ≥ 14px bold) | 3:1 | 4.5:1 |
| UI Components | 3:1 | — |
/* Example of sufficient contrast */
.good-contrast {
color: #1a1a1a; /* dark text */
background: #ffffff; /* white background */
/* Contrast: 16.1:1 ✓ */
}
/* Insufficient contrast */
.bad-contrast {
color: #767676; /* gray text */
background: #ffffff; /* white background */
/* Contrast: 4.48:1 — barely passes AA */
}
<template>
<!-- Bad: only color indicates an error -->
<input :class="{ 'border-red-500': hasError }">
<!-- Good: color + icon + text -->
<div>
<input
:class="{ 'border-red-500': hasError }"
:aria-invalid="hasError"
aria-describedby="error-message"
>
<p v-if="hasError" id="error-message" class="text-red-500">
<span aria-hidden="true">⚠️</span>
Enter a valid email
</p>
</div>
</template>
The official library with built-in accessibility, built on Reka UI:
npx nuxi module add @nuxt/ui
What's included:
<template>
<!-- Accessible dialog out of the box -->
<UModal v-model="isOpen">
<UCard>
<template #header>
Confirmation
</template>
<p>Are you sure?</p>
<template #footer>
<UButton @click="confirm">Yes</UButton>
<UButton variant="ghost" @click="isOpen = false">
Cancel
</UButton>
</template>
</UCard>
</UModal>
</template>
Unstyled primitives with full accessibility:
npm install reka-ui
<script setup lang="ts">
import { AccordionRoot, AccordionItem, AccordionTrigger, AccordionContent } from 'reka-ui'
</script>
<template>
<AccordionRoot type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Question 1</AccordionTrigger>
<AccordionContent>Answer 1</AccordionContent>
</AccordionItem>
</AccordionRoot>
</template>
Copy-paste components based on Radix Vue:
npx shadcn-vue@radix init
npx shadcn-vue@radix add button dialog
npm install -D eslint-plugin-vuejs-accessibility
// eslint.config.js (ESLint 9+)
import pluginVueA11y from 'eslint-plugin-vuejs-accessibility'
export default [
...pluginVueA11y.configs['flat/recommended'],
{
rules: {
'vuejs-accessibility/alt-text': 'error',
'vuejs-accessibility/anchor-has-content': 'error',
'vuejs-accessibility/click-events-have-key-events': 'error',
'vuejs-accessibility/label-has-for': 'error'
}
}
]
Integrates axe-core into DevTools:
npm install -D nuxt-a11y
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-a11y']
})
The module shows accessibility violations in the browser console.
| Tool | Purpose |
|---|---|
| axe DevTools | Browser extension for auditing |
| WAVE | Visual audit of the page |
| Lighthouse | Built into Chrome DevTools |
| NVDA / VoiceOver | Real screen readers |
| Keyboard | Navigation without a mouse |
Cmd + F5Recommendation: Regularly test with a real screen reader. Automated tools find only ~30% of problems.
<html lang="en">)alt textalt=""labelaria-required)fieldset