Aller au contenu principal
Accessibilité16 avril 202614 min de lecture

Accessibilité RGAA pour Vue.js : guide technique 2026

Guide technique complet pour rendre tes composants Vue.js conformes au RGAA 4.1. Erreurs courantes, directives utiles et tests avec vue-axe.

L'accessibilité RGAA dans un projet Vue.js ne se résume pas à ajouter quelques attributs aria-label après coup. Le framework introduit des comportements spécifiques — rendu conditionnel, routage client-side, composants dynamiques — qui cassent silencieusement l'accessibilité si tu ne les maîtrises pas. En 2026, avec l'EAA en vigueur et des amendes de 50 000 euros par service non conforme, ignorer ces problèmes n'est plus une option. Ce guide couvre les erreurs les plus fréquentes dans les projets Vue.js, les solutions concrètes et les outils pour tester ta conformité RGAA.

Pourquoi Vue.js pose des problèmes spécifiques d'accessibilité

Vue.js est un framework réactif : il manipule le DOM dynamiquement, gère le routage côté client et utilise un système de composants qui encapsule la logique d'affichage. Ces trois caractéristiques créent des pièges d'accessibilité que le HTML statique n'a pas.

Le rendu conditionnel avec v-if et v-show supprime ou masque des éléments du DOM sans prévenir les technologies d'assistance. Le routage avec vue-router change le contenu de la page sans rechargement, ce qui empêche les lecteurs d'écran de détecter la navigation. Les composants encapsulés fragmentent la structure sémantique et rendent les relations ARIA complexes à maintenir.

Résultat : un projet Vue.js peut afficher un score Lighthouse de 90/100 tout en étant inutilisable avec NVDA ou VoiceOver. Les tests automatisés ne captent pas ces problèmes liés au cycle de vie du framework. Il faut les connaître pour les anticiper.

Erreur 1 : v-if qui casse la gestion du focus

Le problème

Quand tu utilises v-if pour afficher ou masquer un bloc (modale, menu, alerte), l'élément est supprimé du DOM puis recréé. Si l'utilisateur naviguait au clavier à l'intérieur de ce bloc, le focus est perdu : il retombe sur le <body>, obligeant l'utilisateur à re-tabuler depuis le début de la page.

Non conforme :

<template>
  <button @click="showModal = true">Ouvrir</button>
  <div v-if="showModal" class="modal">
    <h2>Confirmation</h2>
    <p>Es-tu sûr de vouloir continuer ?</p>
    <button @click="showModal = false">Fermer</button>
  </div>
</template>

Quand l'utilisateur ferme la modale, le focus disparaît dans le vide. Critères RGAA violés : 7.1 (compatibilité avec les technologies d'assistance), 7.3 (contrôle par le clavier), 10.7 (visibilité du focus).

La solution

Gère le focus explicitement : quand la modale s'ouvre, place le focus sur le premier élément interactif à l'intérieur. Quand elle se ferme, replace le focus sur l'élément qui l'a déclenchée. Utilise ref et nextTick pour synchroniser avec le cycle de rendu de Vue.

Conforme :

<template>
  <button ref="trigger" @click="openModal">Ouvrir</button>
  <div
    v-if="showModal"
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title"
    @keydown.esc="closeModal"
  >
    <h2 id="modal-title">Confirmation</h2>
    <p>Es-tu sûr de vouloir continuer ?</p>
    <button ref="closeBtn" @click="closeModal">Fermer</button>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'

const showModal = ref(false)
const trigger = ref(null)
const closeBtn = ref(null)

async function openModal() {
  showModal.value = true
  await nextTick()
  closeBtn.value?.focus()
}

function closeModal() {
  showModal.value = false
  trigger.value?.focus()
}
</script>

Points clés : role="dialog" et aria-modal="true" informent le lecteur d'écran qu'il s'agit d'une modale. aria-labelledby associe le titre. @keydown.esc permet de fermer au clavier. Le focus est géré dans les deux sens.

Piège du focus trap

Pour les modales, tu dois aussi piéger le focus à l'intérieur : quand l'utilisateur tabule au-delà du dernier élément interactif, le focus doit revenir au premier élément de la modale, pas à la page derrière. Utilise une librairie comme focus-trap ou implémente-le manuellement en écoutant les événements keydown sur Tab.

<script setup>
import { createFocusTrap } from 'focus-trap'

let trap = null

async function openModal() {
  showModal.value = true
  await nextTick()
  const modalEl = document.querySelector('[role="dialog"]')
  trap = createFocusTrap(modalEl, { initialFocus: closeBtn.value })
  trap.activate()
}

function closeModal() {
  trap?.deactivate()
  showModal.value = false
  trigger.value?.focus()
}
</script>

Erreur 2 : router-link sans contexte accessible

Le problème

Le composant <router-link> de Vue Router génère des balises <a> avec le texte que tu fournis dans le slot. Mais les développeurs écrivent souvent des intitulés vagues ou identiques sur plusieurs liens.

Non conforme :

<router-link to="/offres">En savoir plus</router-link>
<router-link to="/tarifs">En savoir plus</router-link>
<router-link to="/contact">Cliquer ici</router-link>

Trois liens avec le même intitulé « En savoir plus » : un lecteur d'écran les restitue de façon identique. L'utilisateur ne sait pas où chaque lien mène. Critère RGAA violé : 6.1 (chaque lien est-il explicite ?).

La solution

Rends chaque intitulé de lien explicite. Si le design impose un texte court, utilise aria-label pour fournir un contexte complet aux technologies d'assistance.

Conforme :

<router-link to="/offres" aria-label="Découvrir nos offres d'audit accessibilité">
  En savoir plus
</router-link>
<router-link to="/tarifs" aria-label="Consulter nos tarifs">
  En savoir plus
</router-link>
<router-link to="/contact" aria-label="Nous contacter">
  Nous contacter
</router-link>

Mieux encore, utilise directement des intitulés explicites quand le design le permet — c'est préférable à un aria-label qui n'est pas visible par les utilisateurs voyants.

Annoncer les changements de route

Vue Router ne recharge pas la page lors d'une navigation. Le lecteur d'écran ne sait donc pas qu'un nouveau contenu est apparu. Tu dois annoncer le changement de page via une aria-live region.

<!-- App.vue -->
<template>
  <div aria-live="polite" class="sr-only" role="status">
    {{ routeAnnouncement }}
  </div>
  <router-view />
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const routeAnnouncement = ref('')

watch(() => route.path, () => {
  routeAnnouncement.value = `Page chargée : ${document.title}`
})
</script>

<style>
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>

Cette technique utilise une region aria-live="polite" qui est lue automatiquement par les lecteurs d'écran quand son contenu change. La classe sr-only la masque visuellement sans la retirer du DOM.

Erreur 3 : absence de skip-link

Le problème

En SPA (Single Page Application), le menu de navigation est présent sur chaque vue. Un utilisateur au clavier ou avec un lecteur d'écran doit tabuler à travers tous les liens du menu avant d'atteindre le contenu principal — à chaque changement de page. Sans skip-link, un site avec 15 liens dans le header nécessite 15 appuis sur Tab avant d'atteindre le premier contenu utile.

Critère RGAA violé : 12.7 (lien d'évitement ou d'accès rapide à la zone de contenu principal).

La solution

Ajoute un skip-link en premier élément du DOM, visible au focus clavier, qui pointe vers le <main>.

<!-- App.vue -->
<template>
  <a href="#main-content" class="skip-link">
    Aller au contenu principal
  </a>
  <Navbar />
  <main id="main-content" tabindex="-1">
    <router-view />
  </main>
  <Footer />
</template>

<style>
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  background: #1e40af;
  color: #ffffff;
  padding: 0.75rem 1.5rem;
  z-index: 9999;
  font-weight: 600;
  text-decoration: none;
  border-radius: 0 0 0.5rem 0;
}

.skip-link:focus {
  top: 0;
}
</style>

Le tabindex="-1" sur <main> permet au skip-link de déplacer le focus programmatiquement vers le contenu principal. Sans cet attribut, le lien d'ancrage ne fonctionne pas correctement dans certains navigateurs.

Attention au routage client-side : après un changement de route, le focus reste souvent sur le lien cliqué ou sur le <body>. Tu dois replacer le focus sur le <main> ou sur le <h1> de la nouvelle page pour que le skip-link reste utile.

// router/index.js
router.afterEach(() => {
  nextTick(() => {
    const main = document.getElementById('main-content')
    if (main) main.focus()
  })
})

Erreur 4 : formulaires sans label associé

Le problème

Dans les composants Vue.js, les formulaires sont souvent construits avec des composants enfants réutilisables (champ de texte, sélecteur, case à cocher). L'attribut for du <label> doit correspondre à l'id du <input>. Mais quand le composant est instancié plusieurs fois, les id doivent être uniques — sinon plusieurs labels pointent vers le même champ.

Non conforme :

<!-- BaseInput.vue — composant réutilisable -->
<template>
  <div>
    <label for="input">{{ label }}</label>
    <input id="input" :type="type" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
  </div>
</template>

Si tu utilises <BaseInput label="Nom" /> et <BaseInput label="Email" /> sur la même page, les deux inputs ont id="input" — le HTML est invalide et le second label pointe vers le premier champ. Critères RGAA violés : 11.1 (chaque champ a-t-il une étiquette ?), 11.2 (chaque étiquette est-elle pertinente ?).

La solution

Génère des id uniques. Vue 3 fournit useId() depuis la version 3.5, ou utilise un compteur.

Conforme :

<!-- BaseInput.vue -->
<template>
  <div>
    <label :for="inputId">{{ label }}</label>
    <input
      :id="inputId"
      :type="type"
      :value="modelValue"
      :autocomplete="autocomplete"
      :aria-describedby="helpText ? helpId : undefined"
      :aria-invalid="error ? 'true' : undefined"
      @input="$emit('update:modelValue', $event.target.value)"
    >
    <p v-if="helpText" :id="helpId" class="help-text">{{ helpText }}</p>
    <p v-if="error" role="alert" class="error-text">{{ error }}</p>
  </div>
</template>

<script setup>
import { useId } from 'vue'

defineProps({
  label: String,
  type: { type: String, default: 'text' },
  modelValue: String,
  autocomplete: String,
  helpText: String,
  error: String,
})

defineEmits(['update:modelValue'])

const inputId = useId()
const helpId = `${inputId}-help`
</script>

Points clés : useId() garantit l'unicité. aria-describedby lie le texte d'aide au champ. aria-invalid signale une erreur. role="alert" fait annoncer le message d'erreur au lecteur d'écran. L'attribut autocomplete satisfait le critère RGAA 11.13 (autocomplétion).

Erreur 5 : composants interactifs sans sémantique

Le problème

Les développeurs Vue utilisent souvent des <div> ou des <span> avec des événements @click pour créer des éléments interactifs. Ces éléments ne sont ni focusables au clavier, ni annoncés correctement par les lecteurs d'écran.

Non conforme :

<template>
  <div class="tab" @click="selectTab('general')">Général</div>
  <div class="tab" @click="selectTab('securite')">Sécurité</div>
  <div class="tab-content">{{ currentContent }}</div>
</template>

Un utilisateur au clavier ne peut pas atteindre ces « onglets ». Un lecteur d'écran les annonce comme du texte, pas comme des onglets interactifs. Critères RGAA violés : 7.1 (compatibilité avec les technologies d'assistance), 7.3 (contrôle par le clavier).

La solution

Utilise les rôles ARIA du pattern Tabs WAI-ARIA, ou mieux, utilise des éléments HTML natifs quand c'est possible.

Conforme :

<template>
  <div role="tablist" aria-label="Paramètres du compte">
    <button
      v-for="tab in tabs"
      :key="tab.id"
      role="tab"
      :id="`tab-${tab.id}`"
      :aria-selected="activeTab === tab.id"
      :aria-controls="`panel-${tab.id}`"
      :tabindex="activeTab === tab.id ? 0 : -1"
      @click="selectTab(tab.id)"
      @keydown="handleKeydown($event, tab.id)"
    >
      {{ tab.label }}
    </button>
  </div>
  <div
    v-for="tab in tabs"
    :key="tab.id"
    role="tabpanel"
    :id="`panel-${tab.id}`"
    :aria-labelledby="`tab-${tab.id}`"
    :hidden="activeTab !== tab.id"
  >
    <slot :name="tab.id" />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({ tabs: Array })
const activeTab = ref(props.tabs[0].id)

function selectTab(id) {
  activeTab.value = id
}

function handleKeydown(event, currentId) {
  const ids = props.tabs.map(t => t.id)
  const index = ids.indexOf(currentId)
  let newIndex = index

  if (event.key === 'ArrowRight') newIndex = (index + 1) % ids.length
  else if (event.key === 'ArrowLeft') newIndex = (index - 1 + ids.length) % ids.length
  else if (event.key === 'Home') newIndex = 0
  else if (event.key === 'End') newIndex = ids.length - 1
  else return

  event.preventDefault()
  selectTab(ids[newIndex])
  document.getElementById(`tab-${ids[newIndex]}`)?.focus()
}
</script>

Le pattern complet inclut : les rôles tablist, tab et tabpanel, la navigation par flèches directionnelles, la gestion de aria-selected et du tabindex roving, et les raccourcis Home/End.

Directives Vue utiles pour l'accessibilité

Directive v-focus

Place automatiquement le focus sur un élément quand il apparaît dans le DOM. Indispensable pour les modales, les messages d'erreur et les contenus dynamiques.

// directives/v-focus.js
export const vFocus = {
  mounted(el) {
    el.focus()
  }
}

Utilisation :

<input v-focus v-if="showSearch" type="search" aria-label="Rechercher" />

Directive v-trap-focus

Piège le focus à l'intérieur d'un conteneur (modale, panneau latéral).

// directives/v-trap-focus.js
export const vTrapFocus = {
  mounted(el) {
    const focusable = el.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable[0]
    const last = focusable[focusable.length - 1]

    el._trapHandler = (e) => {
      if (e.key !== 'Tab') return
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first.focus()
      }
    }

    el.addEventListener('keydown', el._trapHandler)
    first?.focus()
  },
  unmounted(el) {
    el.removeEventListener('keydown', el._trapHandler)
  }
}

Directive v-announce

Annonce un message aux technologies d'assistance via une live region.

// directives/v-announce.js
let announcer = null

function getAnnouncer() {
  if (!announcer) {
    announcer = document.createElement('div')
    announcer.setAttribute('role', 'status')
    announcer.setAttribute('aria-live', 'polite')
    announcer.setAttribute('aria-atomic', 'true')
    announcer.className = 'sr-only'
    document.body.appendChild(announcer)
  }
  return announcer
}

export const vAnnounce = {
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      const a = getAnnouncer()
      a.textContent = ''
      setTimeout(() => { a.textContent = binding.value }, 100)
    }
  }
}

Tester l'accessibilité avec vue-axe

Installation

vue-axe intègre le moteur axe-core directement dans l'environnement de développement Vue. Les violations s'affichent dans la console du navigateur en temps réel, pendant que tu développes.

npm install -D vue-axe@next axe-core

Configuration dans main.js (uniquement en développement) :

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

if (import.meta.env.DEV) {
  import('vue-axe').then(({ default: VueAxe }) => {
    app.use(VueAxe, {
      delay: 500,
      clearConsoleOnUpdate: false,
    })
    app.mount('#app')
  })
} else {
  app.mount('#app')
}

Ce que vue-axe détecte

  • Contrastes de couleurs insuffisants
  • Images sans attribut alt
  • Champs de formulaire sans label
  • Rôles ARIA invalides ou manquants
  • Liens sans intitulé accessible
  • Éléments interactifs non focusables
  • Structure de titres incohérente

Ce que vue-axe ne détecte pas

  • La pertinence des alternatives textuelles (il vérifie la présence, pas le sens)
  • Les problèmes de focus liés au routage client-side
  • La qualité de la navigation clavier sur les composants custom
  • La compatibilité réelle avec les lecteurs d'écran
  • Les problèmes de focus trap dans les modales

C'est pourquoi un outil de test automatisé comme vue-axe doit être complété par des tests manuels et un scan global de conformité RGAA.

Tests automatisés en CI/CD

Intègre axe-core dans tes tests Cypress ou Playwright pour détecter les régressions d'accessibilité avant chaque déploiement.

// cypress/e2e/accessibility.cy.js
describe('Tests accessibilité', () => {
  beforeEach(() => {
    cy.injectAxe()
  })

  it('La page d\'accueil ne contient pas de violation critique', () => {
    cy.visit('/')
    cy.checkA11y(null, {
      includedImpacts: ['critical', 'serious']
    })
  })

  it('Le formulaire de contact est accessible', () => {
    cy.visit('/contact')
    cy.checkA11y('form')
  })
})

Checklist rapide RGAA pour un composant Vue

Avant de fusionner un composant Vue, vérifie ces points :

  1. Sémantique HTML — Tu utilises <button>, <a>, <input>, <select>, <nav>, <main>, <header>, <footer> plutôt que des <div> avec des événements.
  2. Labels de formulaire — Chaque <input> a un <label> avec un for qui pointe vers un id unique (utilise useId()).
  3. Alternatives textuelles — Chaque <img> informative a un alt pertinent. Chaque image décorative a alt="".
  4. Navigation clavier — Tout élément interactif est accessible avec Tab, activable avec Entrée ou Espace, et fermable avec Échap si pertinent.
  5. Gestion du focusv-if ne laisse jamais le focus dans le vide. Les modales piègent le focus. Le routage replace le focus.
  6. Contrastes — Texte et fond respectent un ratio minimum de 4.5:1 (3:1 pour le texte agrandi).
  7. ARIA minimal — Tu n'ajoutes ARIA que quand le HTML natif ne suffit pas. Pas de role="button" sur un <button>.
  8. Live regions — Les contenus dynamiques (alertes, notifications, résultats de recherche) utilisent aria-live.

Comment WebConforme aide les développeurs Vue

Un scan WebConforme analyse la page rendue (pas le code source Vue) et détecte les violations RGAA visibles dans le DOM final. C'est exactement ce que voient les lecteurs d'écran et les navigateurs. Le rapport inclut :

  • Les critères RGAA violés avec leur numéro et leur description
  • Le code HTML fautif tel qu'il est rendu dans le navigateur
  • Le code corrigé à appliquer
  • Un score de conformité par thématique RGAA

Pour un projet Vue.js, c'est le complément idéal de vue-axe : vue-axe teste en développement pendant que tu codes, WebConforme teste l'application déployée telle que les utilisateurs la voient.

Pour aller plus loin sur les critères RGAA et comprendre ce que couvre chaque thématique, consulte le guide complet des 106 critères RGAA. Et pour tester immédiatement ton application Vue.js déployée, lance un test d'accessibilité gratuit avec WebConforme.

Conclusion

Vue.js n'est ni plus ni moins accessible qu'un autre framework — c'est ce que tu en fais qui détermine la conformité RGAA. Les cinq erreurs couvertes dans ce guide (focus perdu avec v-if, liens sans contexte, absence de skip-link, formulaires sans label unique, composants sans sémantique) représentent la majorité des violations que nous constatons dans les projets Vue.js audités. Corrige-les, intègre vue-axe dans ton workflow de développement, et lance un scan WebConforme pour valider le résultat. L'accessibilité n'est pas un sprint final avant la mise en production — c'est une discipline de chaque composant, de chaque commit.


Teste la conformité RGAA de ton application Vue.js en 30 secondes sur webconforme.fr — premier scan gratuit, sans inscription.

Votre site est-il conforme au RGAA ?

Scan gratuit en 30 secondes — aucune inscription requise.

Lancer un scan gratuit
#Vue.js#RGAA#accessibilité#développeurs#guide technique#2026