Hook — 05 : 55 a.m., Cartagena ↔ Mexico City QA blitz
Tropical birds screeched outside my Colombian balcony when Elena, testing from a cowork in CDMX, discovered that our slick Vue.js dashboard trapped focus inside a hidden sidebar. Her screen reader repeated “button, button” until she force–quit Chrome. Ten minutes, a<nav role="navigation">
, and a handful of keyboard traps later, the audit passed with flying colors—and Elena still made the morning churro run. That hot-fix fuels today’s deep dive into building truly accessible Vue 3 apps that welcome every user, whether they click, tap, or tab.
Why Accessibility Can’t Be an Afterthought
- 15 % of the world lives with some form of disability; leaving them out is both unethical and unprofitable.
- Search engines reward semantic markup; ARIA roles boost SEO.
- Many countries enforce accessibility laws with hefty fines.
- Keyboard-only and screen-reader users file bounce rates you never see in analytics—until they tweet about it.
The good news? Vue.js’s reactivity works in harmony with native HTML semantics, so a11y fixes rarely add bundle weight.
Core Concepts in Plain English
- ARIA (Accessible Rich Internet Applications) roles and attributes add meaning — telling assistive tech that a div is a button, a modal, or a listbox.
- Focus management ensures keyboard users know where they are and how to leave.
- Logical tab order flows with the DOM;
tabindex
should be the exception, not the rule. - Keyboard shortcuts must never steal focus or trap users.
Hands-On: Building an Accessible Off-Canvas Menu
vueCopyEdit<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
const open = ref(false);
const toggle = async () => {
open.value = !open.value;
await nextTick();
if (open.value) document.getElementById('drawer')?.focus();
};
const closeOnEsc = (e: KeyboardEvent) => open.value && e.key === 'Escape' && toggle();
onMounted(() => window.addEventListener('keydown', closeOnEsc));
</script>
<template>
<button @click="toggle" aria-controls="drawer" :aria-expanded="open">Menu</button>
<transition name="slide">
<aside
v-if="open"
id="drawer"
role="dialog"
aria-label="Main Navigation"
tabindex="-1"
class="drawer"
>
<nav role="navigation">
<a href="/pricing">Pricing</a>
<a href="/docs">Docs</a>
<button @click="toggle">Close</button>
</nav>
</aside>
</transition>
</template>
<style scoped>
.drawer { outline:none; width:16rem; background:#fff; }
.slide-enter-from, .slide-leave-to { transform:translateX(-100%); }
.slide-enter-active, .slide-leave-active { transition:transform .3s ease; }
</style>
Line-by-line
role="dialog"
identifies modal semantics.tabindex="-1"
lets us programmatically focus the drawer.aria-controls
+aria-expanded
tie trigger to panel for screen readers.- Escape key listener closes the menu—essential for keyboard users.
Coding a Roving Tabindex for a Custom Listbox
vueCopyEdit<script setup lang="ts">
import { ref } from 'vue';
const items = ['🇨🇴 COP', '🇧🇷 BRL', '🇲🇽 MXN'];
const index = ref(0);
const choose = (i:number) => index.value = i;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') index.value = (index.value + 1) % items.length;
if (e.key === 'ArrowUp') index.value = (index.value - 1 + items.length) % items.length;
};
</script>
<template>
<ul role="listbox" @keydown="onKey">
<li
v-for="(label,i) in items"
:key="label"
role="option"
:aria-selected="i===index"
:tabindex="i===index?0:-1"
@click="choose(i)"
@focus="index=i"
>{{ label }}</li>
</ul>
</template>
Vue’s reactive index
updates tabindex
and aria-selected
instantly—no hidden timers.
Remote-Work Insight Box
During a sprint in Brazil we discovered our chart tooltips weren’t announced by screen readers. We added role="status"
to an off-screen div and fed it live data via Vue’s reactivity. Portuguese NVDA users now hear “Revenue increased 12 percent this quarter” with each bar hover, and QA in Panama reported zero a11y blockers for the first time.
Performance & Accessibility Checkpoints
- Lighthouse → Accessibility should hit 100; automated tests catch 30 % of issues—manual keyboard sweeps still required.
- Color contrast: use CSS custom properties (
--text-primary
) and check them against WCAG AA. - Reduced motion preference: wrap heavy animations in
@media (prefers-reduced-motion: reduce)
. - Focus outlines: never remove them; restyle instead (
outline:2px solid var(--accent)
). - Semantic HTML first: only reach for
role
when native tags don’t exist.
Common Pitfalls & Swift Fixes
Symptom | Root Cause | Remedy |
---|---|---|
Focus stuck behind modal | Forgot to trap tab loop | Roving tabindex inside dialog , inert on backdrop |
Screen reader says “button” twice | Duplicate role and native element | Remove role when using <button> |
Arrow keys scroll page | preventDefault() missing | Call it in listbox key handler |
Tooltip ignored by AT | display:none on text | Use aria-live region off-screen |
Call-Out Reference Table
Tool / Concept | One-liner Purpose |
---|---|
role="dialog" | Informs AT of modal context |
aria-live="polite" | Announces async updates |
tabindex="-1" | Programmatic focus target |
focus-visible CSS | Show outline only on keyboard |
inert attribute | Blocks tabbing outside modal (Safari polyfill needed) |
CLI Nuggets
Command | Job |
---|---|
npm i axe-core --save-dev | Automate a11y tests in Cypress |
npx lighthouse --only-categories=accessibility | Quick audit |
vue add vue-a11y | ESLint rules for ARIA misuse |
Diagram Description
Keyboard focus flow: Trigger button → drawer dialog
→ loop through nav links → Escape → focus returns to trigger. Arrows illustrate focus trap.
Wrap-Up
Accessible Vue.js apps aren’t rocket science: build with native HTML, sprinkle ARIA only when needed, and guard keyboard flow like a bouncer at a beachfront salsa club. Follow these patterns, and users with screen readers—or just a broken trackpad in a Dominican café—will cruise your interface without detours.
Share your own a11y wins or stumbles below; I’ll reply between flights and late-night arepa sessions.