DEV Community

Cover image for Best practices for Accessibility in Vue / Nuxt
Jakub Andrzejewski
Jakub Andrzejewski

Posted on

Best practices for Accessibility in Vue / Nuxt

Accessibility isn’t just a checklist—it’s about making your application usable for everyone, regardless of their abilities, devices, or environment. In the Vue and Nuxt ecosystem, accessibility (a11y) is often overlooked until late in development. The good news? Small, intentional changes can make a massive impact.

This guide will walk you through practical, tested accessibility practices for Vue 3 and Nuxt 3 apps.

Enjoy!

🤔 Why Accessibility Matters in Vue/Nuxt?

  • Legal compliance — In many regions, accessible websites are legally required.
  • Better UX for everyone — Keyboard navigation, clear structure, and readable contrast improve usability for all users.
  • SEO benefits — Search engines love well-structured, semantic HTML.

🟢 Improving Accessibility in Vue/Nuxt

Below, you will see a list of common Accessiblity improvement patterns that I try to implement each day in projects - both work and community ones :)

1. Start With Semantic HTML

Accessibility begins before Vue renders a single component.
Avoid <div> soup by using meaningful HTML tags:

<!-- ❌ Bad -->
<div class="title">Welcome</div>

<!-- ✅ Good -->
<h1>Welcome</h1>
Enter fullscreen mode Exit fullscreen mode

In Vue templates:

  • Use <main>, <nav>, <header>, <footer>, <section> appropriately.
  • Reserve <button> for clickable actions—not <div> with a @click.

2. Manage Focus & Announce Route Changes

When routes change in Nuxt, screen readers need to know where to start reading again.

Example: Focus the page’s main heading after navigation:

<script setup>
import { onMounted, ref } from 'vue'
const headingRef = ref(null)

onMounted(() => {
  headingRef.value?.focus()
})
</script>

<template>
  <h1 ref="headingRef" tabindex="-1">Dashboard</h1>
</template>
Enter fullscreen mode Exit fullscreen mode

Nuxt 3 ships with a built-in Route Announcer that automatically notifies assistive technologies when the page changes.
It adds a visually hidden <div> with aria-live="assertive" so screen readers announce the new page title without extra setup.

How to use:

  • It’s enabled by default in Nuxt 3.
  • You can customize the announcement text using Nuxt hooks or middleware if your app needs special phrasing.
  • Combine it with focus management so both sighted and non-sighted users get the right context after navigation.

3. Ensure Proper Color Contrast

Tailwind makes adjusting colors easy, but that also makes it easy to pick low-contrast combos.

Check WCAG guidelines:

Tailwind tip: Define accessible colors in tailwind.config.js so they’re used consistently.

4. Use ARIA Attributes Wisely

ARIA helps only when HTML alone can’t convey meaning.

Examples:

<!-- Dialog -->
<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
  <h2 id="dialog-title">Confirm Delete</h2>
</div>

<!-- Live region for updates -->
<div aria-live="polite">
  {{ statusMessage }}
</div>
Enter fullscreen mode Exit fullscreen mode

Don’t use ARIA to “patch” incorrect HTML. Fix the markup first.

5. Make Components Keyboard-Friendly

Every interactive element must be reachable and operable via keyboard.

Example for a custom dropdown:

<template>
  <div @keydown.down.prevent="focusNext" @keydown.up.prevent="focusPrev">
    <!-- items -->
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Checklist:

  • Tab moves between items.
  • Enter/Space activates actions.
  • Escape closes modals/menus.

6. Provide Skip Links

For long pages, let keyboard users skip straight to the main content:

<a href="#main" class="sr-only focus:not-sr-only">Skip to content</a>
<main id="main"> ... </main>
Enter fullscreen mode Exit fullscreen mode

7. Test with Real Tools

Automated checks are helpful, but nothing beats real-world testing:

  • Lighthouse (Chrome DevTools)
  • axe DevTools
  • Screen readers — VoiceOver (macOS/iOS), NVDA (Windows), TalkBack (Android)
  • Keyboard-only navigation — Try not touching your mouse for an entire session.

📖 Learn more

If you would like to learn more about Vue, Nuxt, JavaScript or other useful technologies, checkout VueSchool by clicking this link or by clicking the image below:

Vue School Link

It covers most important concepts while building modern Vue or Nuxt applications that can help you in your daily work or side projects 😉

🧪 Advance skills

A certification boosts your skills, builds credibility, and opens doors to new opportunities. Whether you're advancing your career or switching paths, it's a smart step toward success.

Check out Certificates.dev by clicking this link or by clicking the image below:

Certificates.dev Link

Invest in yourself—get certified in Vue.js, JavaScript, Nuxt, Angular, React, and more!

✅ Summary

Accessibility isn’t an extra—it’s a baseline.
In Vue and Nuxt, adopting semantic HTML, managing focus, ensuring contrast, announcing route changes, and testing often will make your app more usable, inclusive, and even search-friendly.

Take care and see you next time!

And happy coding as always 🖥️

Top comments (3)

Collapse
 
userquin profile image
userquin

nice: you'll need to use polite instead assertive router announcer, that's too much noice for an screen reader: Nuxt has changed the default value to polite github.com/nuxt/nuxt/blob/5d783662...

On page refresh you shouldn't announce page loaded, only on page "transition".

If you're running some server action (fetch), you also need to announce the action, avoiding the auditory flickering (if the action takes less than human perceible time (below 400ms) you shouldn't announce the action), for example, using a simple fetch:

export interface FetchData extends Partial<RequestInit> {
  actionMessage: string
}

export interface FetchOptions {
  mute?: boolean
  consume?: boolean
  resetSessionTimer?: boolean
}

export async function doFetch<T = any>(
  input: RequestInfo | URL,
  init: FetchData,
  fetchOptions: FetchOptions = {},
): Promise<T> {
  const {
    mute = false,
    consume = true,
    resetSessionTimer = true,
  } = fetchOptions
  const authStore = useAuthStore()
  const { announce } = useAriaAnnouncer()
  const { actionMessage, ...options } = init
  const deferredAnnouncer = setTimeout(() => {
    authStore.busy = true
    if (!mute)
      announce(actionMessage)
  }, ACTION_TIMEOUT)
  try {
    if (resetSessionTimer && authStore.isAuthenticated) {
      window.startOrResetTimer?.()
    }
    const response = await fetch(input, options)
    return consume ? await checkNetworkResponse<T>(response) : response as T
  }
  finally {
    clearTimeout(deferredAnnouncer)
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jacobandrewsky profile image
Jakub Andrzejewski

Hey buddy,

Thanks for this additional context!

Collapse
 
userquin profile image
userquin

will send you a few hints more, can you ping me at discord? you can find me at nuxt, vite or antfu servers