Accessibilité web et WCAG : ce que chaque développeur doit savoir
L'accessibilité n'est pas une option réservée aux grandes entreprises. C'est du code propre qui profite à tous les utilisateurs — et qui influence directement le score INP dans les Core Web Vitals.
L'accessibilité web est souvent présentée comme une contrainte légale ou une case à cocher. C'est une vision courte. Un site accessible est un site qui fonctionne sur tous les appareils, dans toutes les conditions — connexion lente, écran tactile, clavier seul, lecteur d'écran. Ces contraintes forcent à écrire du HTML sémantique, ce qui améliore le SEO.
WCAG (Web Content Accessibility Guidelines) est le standard international. Version actuelle : 2.2. Trois niveaux : A (minimum), AA (cible standard), AAA (excellence). Pour la plupart des projets, atteindre AA suffit.
La connexion avec le SEO et les Core Web Vitals
Ce n'est pas théorique. L'INP (Interaction to Next Paint) mesure la réactivité après interaction — click, tap, clavier. Un site où les interactions bloquent le thread principal = mauvais INP = mauvais signal Core Web Vitals.
Les mêmes patterns qui dégradent l'INP dégradent l'accessibilité :
- Gestionnaires d'événements synchrones lourds
- Absence de focus management après navigation dynamique
- Éléments interactifs non natifs (div cliquable au lieu de button)
<button> gère nativement le focus, les états disabled, les raccourcis clavier, et déclenche des click events au clavier. Un <div onClick> ne fait rien de tout ça.
Les techniques de découpage des Long Tasks qui améliorent l'INP bénéficient aussi aux utilisateurs de lecteurs d'écran.
Structure HTML sémantique
La fondation. Des balises correctes rendent 80% du travail d'accessibilité.
<!-- ❌ Mauvaise structure -->
<div class="header">
<div class="nav">
<div class="nav-item">Accueil</div>
</div>
</div>
<div class="main-content">
<div class="article">
<div class="title">Titre de l'article</div>
<div class="text">Contenu...</div>
</div>
</div>
<!-- ✅ Structure sémantique -->
<header>
<nav aria-label="Navigation principale">
<ul>
<li><a href="/">Accueil</a></li>
<li><a href="/articles">Articles</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Titre de l'article</h1>
<p>Contenu...</p>
</article>
</main>
<footer>
<nav aria-label="Navigation du pied de page">...</nav>
</footer>Les landmarks HTML5 (header, main, nav, footer, aside, section) permettent aux lecteurs d'écran de naviguer directement entre les zones de la page.
ARIA — quand HTML ne suffit pas
ARIA (Accessible Rich Internet Applications) ajoute de la sémantique là où HTML n'a pas de balise native. Règle principale : utiliser ARIA uniquement quand il n'existe pas d'équivalent HTML natif.
<!-- Rôles ARIA pour composants custom -->
<div role="tablist" aria-label="Onglets de configuration">
<button role="tab" aria-selected="true" aria-controls="panel-general" id="tab-general">
Général
</button>
<button role="tab" aria-selected="false" aria-controls="panel-avancé" id="tab-avancé">
Avancé
</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">
<!-- Contenu de l'onglet Général -->
</div>
<!-- Modal -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-desc">
<h2 id="modal-title">Confirmer la suppression</h2>
<p id="modal-desc">Cette action est irréversible.</p>
<button>Annuler</button>
<button>Supprimer</button>
</div>Attributs ARIA essentiels
| Attribut | Usage |
|---|---|
aria-label | Nom accessible quand le texte visible ne suffit pas |
aria-labelledby | Nom référencé depuis un autre élément |
aria-describedby | Description supplémentaire |
aria-hidden="true" | Cacher aux technologies d'assistance |
aria-live="polite" | Annonce les changements dynamiques |
aria-expanded | État ouvert/fermé (accordéon, menu) |
aria-required | Champ obligatoire |
aria-invalid | Champ en erreur |
aria-disabled | Élément désactivé |
<!-- Icône décorative — cacher aux lecteurs d'écran -->
<svg aria-hidden="true" focusable="false">...</svg>
<!-- Bouton icône seule — donner un nom -->
<button aria-label="Fermer le menu">
<svg aria-hidden="true">...</svg>
</button>
<!-- Région mise à jour dynamiquement -->
<div aria-live="polite" aria-atomic="true" id="search-results-count">
12 résultats trouvés
</div>Contraste des couleurs
WCAG AA exige :
- Texte normal (< 18pt) : ratio ≥ 4,5:1
- Grand texte (≥ 18pt ou 14pt bold) : ratio ≥ 3:1
- Composants UI (bordures, icônes actives) : ratio ≥ 3:1 (WCAG 2.1 succès criteria 1.4.11)
/* Vérifier avec DevTools → Accessibilité → Contraste */
/* ❌ Gris clair sur blanc — ratio ~2:1 */
color: #999;
background: #fff;
/* ✅ Texte foncé sur fond clair — ratio >7:1 */
color: #1a1a1a;
background: #fff;
/* Couleur d'accent vérifiée : */
/* #cdff4f sur #0a0a0a → ratio 13.4:1 ✅ */
/* #cdff4f sur #fff → ratio 1.6:1 ❌ */Outils :
- Chrome DevTools → cliquer sur un élément texte → Contraste dans le panneau Accessibilité
- WebAIM Contrast Checker
- Figma plugin "Contrast" ou "A11y - Color Contrast Checker"
Navigation au clavier
Tout ce qui est accessible à la souris doit être accessible au clavier. Test rapide : Tab, Shift+Tab, Entrée, Espace, flèches directionnelles.
<!-- Focus visible — ne JAMAIS supprimer outline sans alternative -->
<style>
/* ❌ Supprimer le focus */
* { outline: none; }
/* ✅ Focus personnalisé visible */
:focus-visible {
outline: 2px solid #cdff4f;
outline-offset: 2px;
}
</style>// Gestion du focus dans un composant modal
function openModal() {
const modal = document.getElementById('modal')
modal.removeAttribute('hidden')
// Déplacer le focus dans la modal
const firstFocusable = modal.querySelector('button, [href], input, select, textarea')
firstFocusable?.focus()
}
function closeModal() {
const modal = document.getElementById('modal')
modal.setAttribute('hidden', '')
// Remettre le focus sur l'élément déclencheur
document.getElementById('open-modal-btn')?.focus()
}
// Piéger le focus dans la modal (Tab loop)
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return
const focusable = modal.querySelectorAll('button, [href], input')
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
last.focus(); e.preventDefault()
} else if (!e.shiftKey && document.activeElement === last) {
first.focus(); e.preventDefault()
}
})Formulaires accessibles
<!-- ❌ Label implicite — fragile -->
<p>Email : <input type="email"></p>
<!-- ✅ Label explicite avec for/id -->
<label for="email">Adresse email</label>
<input type="email" id="email" name="email" autocomplete="email" required>
<!-- ✅ Avec gestion d'erreur -->
<label for="email">
Adresse email
<span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
aria-required="true"
aria-describedby="email-error"
aria-invalid="true"
>
<span id="email-error" role="alert">Format invalide. Exemple : nom@domaine.fr</span>Tester l'accessibilité
Tests automatiques (détectent ~30% des problèmes)
# axe-core en CLI
npm install -g @axe-core/cli
axe https://iducation.fr
# Lighthouse dans CI
lighthouse https://iducation.fr --only-categories=accessibilityimport { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
import { ArticleCard } from '@/components/article-card'
expect.extend(toHaveNoViolations)
test('ArticleCard has no accessibility violations', async () => {
const { container } = render(<ArticleCard title="Test" />)
expect(await axe(container)).toHaveNoViolations()
})Tests manuels (indispensables)
- Navigation clavier uniquement — débranchez la souris, naviguez avec Tab
- Lecteur d'écran — NVDA (Windows, gratuit) ou VoiceOver (Mac, Cmd+F5)
- Zoom 200% — tester la lisibilité et l'absence de contenu coupé
- Mode contraste élevé — Windows : Ctrl+Alt+Del → accessibilité
Accessibilité et performance convergent : les deux exigent du HTML natif sémantique, des interactions réactives, et une structure logique. Si votre site passe les tests Lighthouse accessibilité et l'audit performance-web-lighthouse, vous avez une fondation solide. Ce que Lighthouse ne teste pas — navigation clavier, comportement des lecteurs d'écran, contenu dynamique — nécessite des tests manuels que rien ne peut remplacer.