Accessibility in Nuxt: What It Is, How It Works, and How to Optimize

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.
What is Web Accessibility?
Accessibility isn't just about people with disabilities. It also covers:
- Users with temporary limitations (a broken arm, bright sunlight)
- Elderly people with deteriorating vision
- Users with slow internet connections or old devices
- People who only use a keyboard
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.
Why It's Important
| 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 |
The WCAG Standard: Levels and Principles
WCAG (Web Content Accessibility Guidelines) is the international accessibility standard from the W3C. The current version is WCAG 2.2.
The Four Principles (POUR)
- Perceivable — Content must be presented in a way that can be perceived.
- Operable — The interface must be operable.
- Understandable — Information must be understandable.
- Robust — Content must work with various technologies.
Levels of Conformance
| 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.
Key WCAG 2.2 AA Requirements
- Text contrast: minimum 4.5:1 for normal text, 3:1 for large text
- Keyboard accessibility for all functions
- Visible focus on interactive elements
- Alternative text for images
- Ability to stop automatic content
Why Nuxt is Well-Suited for Accessibility
Nuxt has several built-in mechanisms that make it easier to create accessible applications.
Server-Side Rendering
SSR provides a complete markup on the initial load. Screen readers get all the content without waiting for JavaScript.
Route Announcer
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.
Automatic Title
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 Markup and ARIA
Semantic HTML is the foundation of accessibility. Use the correct elements instead of <div> and <span>.
Proper Heading Structure
<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>.
Landmarks
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 Attributes
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.
Keyboard Navigation
Every interactive element must be accessible via the keyboard.
Basic Keys
| 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 |
Focus Management
<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.
Skip Link
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>
Focus Trap for Modals
<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>
Announcing Changes for Screen Readers
SPAs have a problem: on navigation, the page doesn't reload, and the screen reader doesn't know that something has changed.
Built-in Route Announcer
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".
Custom Announcements
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>
aria-live Modes
| 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.
Contrast and Colors
WCAG Contrast Requirements
| 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 | — |
Checking Contrast
/* 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 */
}
Don't Rely Only on Color
<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>
Accessible UI Libraries for Nuxt
Nuxt UI
The official library with built-in accessibility, built on Reka UI:
npx nuxi module add @nuxt/ui
What's included:
- Automatic ARIA attributes
- Keyboard navigation
- Focus management
- Screen reader support
<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>
Reka UI (Radix Vue)
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>
shadcn-vue
Copy-paste components based on Radix Vue:
npx shadcn-vue@radix init
npx shadcn-vue@radix add button dialog
Testing Tools
ESLint Plugin
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'
}
}
]
nuxt-a11y module
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.
Manual Testing
| 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 |
Testing with a Screen Reader
- VoiceOver (macOS):
Cmd + F5 - NVDA (Windows): a free screen reader
- TalkBack (Android): built-in
Recommendation: Regularly test with a real screen reader. Automated tools find only ~30% of problems.
Accessibility Checklist
Semantics and Structure
- Semantic HTML elements are used
- Headings are in sequential order (h1 → h2 → h3)
- Landmarks are present: header, main, nav, footer
- Page language is specified (
<html lang="en">)
Images and Media
- All informative images have
alttext - Decorative images have
alt="" - Videos have captions
- Audio has a text transcript
Forms
- Every input has an associated
label - Errors are described with text, not just color
- Required fields are marked (
aria-required) - Groups of fields are enclosed in a
fieldset
Navigation
- All functionality is accessible from the keyboard
- Focus is visible on all elements
- A skip link to the main content is present
- Route Announcer is enabled
Colors
- Text contrast is ≥ 4.5:1
- UI component contrast is ≥ 3:1
- Information is conveyed by more than just color
Interactive Elements
- Buttons have accessible names
- Links are descriptive (not "click here")
- Modals have a focus trap
- Dynamic changes are announced