Introduction
We've built fast, feature-rich Vue applications that work beautifully on modern browsers and devices, but if our apps aren't accessible, we're excluding a significant portion of users. Accessibility in Vue apps: practical tips and trade-offs isn't about compliance checkboxes—it's about creating applications that work for everyone, regardless of ability. This guide walks through concrete approaches we've used in production to make Vue components inclusive without sacrificing performance or developer experience.
Semantic HTML in Templates
The foundation of accessible Vue applications is semantic HTML. When we use <button> instead of <div class="btn">, or <nav> instead of <div id="navigation">, we're giving assistive technologies the context they need. Screen readers understand native elements out of the box; custom elements require explicit ARIA attributes.
In a CRM dashboard we maintained, we had a table of user records with action buttons. The original markup looked like this:
<div class="table">
<div class="row" v-for="user in users" :key="user.id">
<div class="cell">{{ user.name }}</div>
<div class="cell">{{ user.email }}</div>
<div class="cell">
<div class="btn" @click="editUser(user.id)">Edit</div>
</div>
</div>
</div>A screen reader user couldn't navigate this efficiently. We refactored to semantic HTML:
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
<button @click="editUser(user.id)">Edit</button>
</td>
</tr>
</tbody>
</table>Now screen readers announce table structure automatically. The scope="col" attribute tells assistive tech which header applies to which column. We also ensured buttons have sufficient color contrast (WCAG AA minimum 4.5:1 for text) and are at least 44×44 pixels for finger-friendly touch targets.
Beyond buttons and tables, we use <label> elements with for attributes for form fields, <fieldset> and <legend> for grouped inputs, and <main>, <section>, <article>, and <aside> to structure page regions. These elements cost nothing in performance and provide massive value to keyboard and screen reader users.
Manage Focus on Route Changes
When users navigate between routes in a single-page application, the main content changes but focus often stays where it was. A keyboard user hitting the page down arrow key might scroll past the new content; a screen reader user lands back at the top of a random landmark. We need to move focus intentionally.
In a travel booking app we worked on, users would search for flights, click a result to view details, then return to the list. Without focus management, they'd be stuck scrolling back to the relevant content each time.
Here's how we handled it with a Nuxt composable:
// composables/useFocusManagement.ts
export const useFocusManagement = () => {
const router = useRouter();
const mainContentRef = ref<HTMLElement | null>(null);
const setMainContentFocus = () => {
nextTick(() => {
if (mainContentRef.value) {
mainContentRef.value.focus();
mainContentRef.value.scrollIntoView({ behavior: 'smooth' });
}
});
};
watch(
() => router.currentRoute.value.path,
() => {
setMainContentFocus();
}
);
return { mainContentRef, setMainContentFocus };
};In the page component:
<template>
<main ref="mainContentRef" tabindex="-1">
<h1>Flight Details</h1>
<!-- content -->
</main>
</template>
<script setup lang="ts">
const { mainContentRef } = useFocusManagement();
</script>The tabindex="-1"> makes the element focusable via JavaScript without adding it to the natural tab order. When the route changes, we move focus to the main content and optionally scroll it into view. This pattern works across modal dialogs too—trap focus inside the modal, and when it closes, return focus to the button that opened it.
In a ride-hailing application, managing focus during dynamic updates (like displaying available drivers as they accept rides) was crucial. We use aria-live="polite" regions to announce updates without disrupting the user's current focus position.
Accessible Component Libraries
Building accessible custom components is time-consuming and error-prone. Established libraries like Radix Vue, Headless UI, and Material Design Components ship with accessibility built in. They handle focus trapping, keyboard navigation, ARIA attributes, and screen reader announcements out of the box.
In an e-commerce checkout flow, we needed a date picker. Building one from scratch meant handling keyboard navigation (arrow keys, home/end keys), mobile touch interaction, announcing selected dates to screen readers, and managing focus during month transitions. Instead, we used a library component:
<template>
<div>
<label for="date-input">Delivery Date</label>
<DatePicker
id="date-input"
v-model="selectedDate"
:min-date="tomorrow"
:max-date="maxDeliveryDate"
@update:model-value="validateDate"
/>
</div>
</template>The library component internally manages:
- Keyboard navigation (arrow keys to change date, enter to confirm)
- Focus visible states for keyboard users
- ARIA attributes for the input and popup container
- Screen reader announcements for month/year changes
- Disabled state styling and interaction
For custom components we do build, we document keyboard interaction and ARIA requirements upfront. A custom tabs component should support arrow keys, Home, End, and Ctrl+PageUp/PageDown. A dropdown button needs role="button", keyboard handlers, and proper ARIA attributes. Libraries like Storybook with the a11y addon help catch issues during development.
Test with Screen Readers
Automated accessibility testing catches maybe 30% of issues. The rest require hands-on testing with actual assistive technology. We test with NVDA (Windows), JAWS (Windows, expensive but industry standard), and VoiceOver (macOS/iOS). For web applications, NVDA is free and covers most users.
In a SaaS CRM application, automated testing passed our complex form component, but when we tested with a screen reader, we discovered the error messages weren't being announced when validation failed. The errors were visually obvious but screen reader users had no way to know. We fixed it by using aria-live and aria-describedby:
<template>
<div>
<label for="email">Email Address</label>
<input
id="email"
v-model="email"
type="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : null"
@blur="validateEmail"
/>
<div
v-if="errors.email"
id="email-error"
role="alert"
aria-live="polite"
>
{{ errors.email }}
</div>
</div>
</template>The role="alert" and aria-live="polite" combination announces the error immediately when it appears. The aria-describedby links the input to the error message so screen readers read them together.
We build screen reader testing into our QA process. Before shipping a feature, we spend 15 minutes with NVDA running, tabbing through the interface, listening to announcements, and checking that focus moves logically. It catches form label issues, missing button text, redundant landmarks, and poor heading hierarchy quickly.
Common Mistakes
- Skipping heading levels.
<h1>then<h3>breaks screen reader navigation. Users expect logical hierarchy. - Using placeholder as label. Placeholder text disappears when focused, leaving no visible label. Always use a separate
<label>element. - Relying on color alone. Red error text means nothing to colorblind users. Add an icon or text prefix like "Error:".
- Keyboard traps. Users get stuck in a modal or dropdown with no way to escape. Always include an escape key handler and a visible close button.
- Ignoring focus styles. Removing
:focus-visibleCSS defaults "for aesthetics" makes keyboard navigation invisible. Style it properly or add a focus ring. - Overusing ARIA.
role="button"on a<div>requires you to handle click, enter, and space key events. Use a real button. - Forgetting mobile context. Accessibility isn't just keyboards and screen readers. Ensure sufficient touch target size and that no interaction requires hover (like tooltips on desktop-only).
Key Takeaways
- Semantic HTML is the cheapest, most effective accessibility tool. Use
<button>,<nav>,<table>, and proper heading hierarchy. - Manage focus explicitly on route changes and dynamic content updates so keyboard and screen reader users aren't lost.
- Prefer accessible component libraries over custom components; they've solved hard problems like focus trapping and keyboard navigation.
- Test with actual screen readers (NVDA, VoiceOver) at least once before shipping; automated testing catches only obvious issues.
- Accessibility has trade-offs—slightly more verbose HTML, extra JavaScript for focus handling—but the payoff in user inclusivity is worth it.
- Keyboard navigation (tab, arrow keys, escape) is your canary in the coal mine; if the keyboard experience is solid, screen reader experience usually follows.






Comments
Loading comments…