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

Accessibilité RGAA pour les développeurs React : guide pratique 2026

Guide technique pour rendre tes applications React conformes au RGAA. Erreurs courantes, hooks utiles, exemples JSX et tests avec WebConforme.

React est le framework front-end le plus utilisé en France en 2026. Il est aussi celui qui génère le plus de violations d'accessibilité — pas parce qu'il est mauvais, mais parce que son modèle de composants et son système de rendu côté client posent des défis spécifiques que la plupart des développeurs ignorent.

Ce guide couvre les erreurs RGAA les plus courantes dans les applications React, les hooks et patterns qui les corrigent, avec des exemples de code JSX prêts à copier-coller. À la fin, tu sauras comment tester automatiquement la conformité de ton application avec WebConforme.

Pourquoi React pose des problèmes spécifiques d'accessibilité

Le rendu côté client casse les attentes des lecteurs d'écran

Une application React classique fonctionne en Single Page Application (SPA). Quand l'utilisateur clique sur un lien, React intercepte la navigation, met à jour le DOM et change l'URL — mais la page ne se recharge pas vraiment. Les lecteurs d'écran comme NVDA ou VoiceOver ne sont pas notifiés qu'un changement de page a eu lieu. L'utilisateur aveugle reste sur le dernier élément focalisé, sans savoir que le contenu a changé.

C'est une violation directe du critère RGAA 12.8 : « Dans chaque page web, l'ordre de tabulation est-il cohérent ? ». Plus largement, ça touche les critères de la thématique 12 (Navigation) du RGAA.

Les composants personnalisés ne sont pas des éléments HTML natifs

Un <button> natif est accessible par défaut : il est focusable, il réagit à la touche Entrée et à la touche Espace, il est annoncé comme « bouton » par les lecteurs d'écran. Un <div onClick={handleClick}> ne fait rien de tout ça. Et pourtant, combien de composants React utilisent des div ou des span comme conteneurs interactifs ?

Le state management crée du contenu dynamique invisible

Quand un useState ou un useReducer met à jour un composant, le DOM change mais les technologies d'assistance ne sont pas systématiquement informées. Un message d'erreur qui apparaît après une soumission de formulaire, un toast de notification, un compteur qui se met à jour — tout ça est invisible pour un lecteur d'écran sans aria-live.

Erreur 1 : les images sans alternative textuelle

Le problème

Le critère RGAA 1.1 exige que chaque image porteuse d'information ait une alternative textuelle. En React, l'erreur se manifeste souvent dans les composants réutilisables où le alt est optionnel.

Code non conforme

// Composant Image sans alt obligatoire
function ProductCard({ product }) {
  return (
    <div className="card">
      <img src={product.image} />
      <h3>{product.name}</h3>
      <p>{product.price} €</p>
    </div>
  );
}

Code conforme

// Composant Image avec alt obligatoire via les props
function ProductCard({ product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.imageAlt} />
      <h3>{product.name}</h3>
      <p>{product.price} €</p>
    </div>
  );
}

// Pour les images décoratives, alt vide explicite
function DecorativeBanner({ src }) {
  return <img src={src} alt="" role="presentation" />;
}

La solution systémique

Utilise un linter ESLint avec le plugin eslint-plugin-jsx-a11y. La règle jsx-a11y/alt-text refusera tout <img> sans attribut alt au moment du build.

npm install eslint-plugin-jsx-a11y --save-dev
{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/recommended"]
}

Avec cette configuration, un composant <img> sans alt génère une erreur ESLint. Impossible de merger du code non conforme.

Erreur 2 : les formulaires sans labels associés

Le problème

Le critère RGAA 11.1 exige que chaque champ de formulaire ait un label correctement associé. En React, le piège c'est htmlFor (au lieu de for en HTML natif) et les champs dynamiques dont l'id n'est pas unique.

Code non conforme

function LoginForm() {
  return (
    <form>
      <input type="email" placeholder="Email" />
      <input type="password" placeholder="Mot de passe" />
      <button type="submit">Se connecter</button>
    </form>
  );
}

Le placeholder n'est pas un label. Il disparaît quand l'utilisateur commence à taper et n'est pas lu de manière fiable par les lecteurs d'écran.

Code conforme avec useId

React 18 a introduit le hook useId qui génère un identifiant unique stable côté serveur et côté client. C'est la solution idéale pour associer labels et champs.

import { useId } from 'react';

function LoginForm() {
  const emailId = useId();
  const passwordId = useId();

  return (
    <form>
      <div>
        <label htmlFor={emailId}>Adresse email</label>
        <input id={emailId} type="email" autoComplete="email" />
      </div>
      <div>
        <label htmlFor={passwordId}>Mot de passe</label>
        <input id={passwordId} type="password" autoComplete="current-password" />
      </div>
      <button type="submit">Se connecter</button>
    </form>
  );
}

Pourquoi useId et pas un id statique ?

Si ton composant est rendu plusieurs fois sur la même page (par exemple dans une liste), un id="email" statique sera dupliqué. Le HTML invalide et le label sera associé au premier élément trouvé, pas au bon. useId garantit l'unicité.

Erreur 3 : la navigation clavier cassée avec React Router

Le problème

Quand React Router change de route, le focus reste sur l'élément qui a déclenché la navigation (le lien ou le bouton). Le contenu de la page change mais le focus est perdu quelque part dans l'ancien DOM. Un utilisateur naviguant au clavier doit tabuler à travers toute la page pour atteindre le nouveau contenu. Un utilisateur de lecteur d'écran ne sait même pas que la page a changé.

C'est une violation des critères RGAA 12.8 (ordre de tabulation) et 7.1 (scripts et accessibilité).

Code non conforme

// App.jsx — React Router sans gestion du focus
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Accueil</Link>
        <Link to="/produits">Produits</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/produits" element={<Products />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </BrowserRouter>
  );
}

Code conforme avec gestion du focus

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

// Hook personnalisé pour gérer le focus au changement de route
function useRouteAnnouncer() {
  const location = useLocation();
  const mainRef = useRef(null);

  useEffect(() => {
    // Déplacer le focus sur le contenu principal à chaque changement de route
    if (mainRef.current) {
      mainRef.current.focus();
    }
  }, [location.pathname]);

  return mainRef;
}

function App() {
  const mainRef = useRouteAnnouncer();

  return (
    <BrowserRouter>
      <nav aria-label="Navigation principale">
        <Link to="/">Accueil</Link>
        <Link to="/produits">Produits</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      <main ref={mainRef} tabIndex={-1}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/produits" element={<Products />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </main>
    </BrowserRouter>
  );
}

Le tabIndex={-1} sur le <main> permet de le rendre focusable programmatiquement (via focus()) sans qu'il apparaisse dans l'ordre de tabulation naturel. À chaque changement de route, le focus est déplacé sur le conteneur principal et le lecteur d'écran commence à lire le nouveau contenu.

Erreur 4 : les mises à jour dynamiques invisibles (aria-live)

Le problème

Le critère RGAA 7.5 exige que les messages de statut soient restitués par les technologies d'assistance. En React, les mises à jour d'état dynamiques sont monnaie courante : messages d'erreur, notifications, compteurs, résultats de recherche en temps réel. Sans aria-live, tout ça est invisible.

Code non conforme

function SearchResults({ query, results }) {
  return (
    <div>
      <p>{results.length} résultats pour « {query} »</p>
      <ul>
        {results.map(r => <li key={r.id}>{r.title}</li>)}
      </ul>
    </div>
  );
}

Quand les résultats se mettent à jour après une frappe dans le champ de recherche, un utilisateur voyant voit le compteur changer. Un utilisateur de lecteur d'écran n'en est pas informé.

Code conforme

function SearchResults({ query, results }) {
  return (
    <div>
      <p aria-live="polite" aria-atomic="true">
        {results.length} résultats pour « {query} »
      </p>
      <ul>
        {results.map(r => <li key={r.id}>{r.title}</li>)}
      </ul>
    </div>
  );
}

aria-live="polite" indique au lecteur d'écran qu'il doit annoncer le contenu mis à jour dès que l'utilisateur est inactif (sans interrompre ce qu'il est en train de lire). aria-atomic="true" force la relecture complète du texte à chaque mise à jour, pas seulement le fragment modifié.

Les trois valeurs de aria-live

  • off (par défaut) : les mises à jour ne sont pas annoncées.
  • polite : les mises à jour sont annoncées quand l'utilisateur est inactif. À utiliser pour les résultats de recherche, les mises à jour de compteur, les messages informatifs.
  • assertive : les mises à jour sont annoncées immédiatement, interrompant le flux de lecture. À réserver aux erreurs critiques (erreur de soumission de formulaire, alerte de sécurité).

Pattern pour les messages d'erreur de formulaire

import { useState, useId } from 'react';

function ContactForm() {
  const [error, setError] = useState('');
  const [success, setSuccess] = useState('');
  const nameId = useId();
  const emailId = useId();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setSuccess('');

    const formData = new FormData(e.target);
    if (!formData.get('name')) {
      setError('Le champ nom est obligatoire.');
      return;
    }

    try {
      await submitForm(formData);
      setSuccess('Message envoyé avec succès.');
    } catch {
      setError('Erreur lors de l\'envoi. Réessaie.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Zone d'annonce pour les erreurs */}
      <div role="alert" aria-live="assertive">
        {error && <p className="error">{error}</p>}
      </div>

      {/* Zone d'annonce pour le succès */}
      <div aria-live="polite">
        {success && <p className="success">{success}</p>}
      </div>

      <div>
        <label htmlFor={nameId}>Nom</label>
        <input id={nameId} name="name" type="text" required />
      </div>
      <div>
        <label htmlFor={emailId}>Email</label>
        <input id={emailId} name="email" type="email" required />
      </div>
      <button type="submit">Envoyer</button>
    </form>
  );
}

Note l'utilisation de role="alert" pour les erreurs : c'est un raccourci sémantique qui équivaut à aria-live="assertive" + aria-atomic="true".

Erreur 5 : les composants interactifs non natifs

Le problème

Les critères RGAA 7.1 et 7.3 exigent que les scripts soient accessibles au clavier et que les composants interactifs soient utilisables avec les technologies d'assistance. En React, la tentation est forte de créer des composants interactifs à partir de <div> ou <span>.

Code non conforme

function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <div className="accordion-header" onClick={() => setIsOpen(!isOpen)}>
        {title}
      </div>
      {isOpen && <div className="accordion-content">{children}</div>}
    </div>
  );
}

Ce composant est inutilisable au clavier. Un <div> n'est pas focusable, ne réagit pas à Entrée/Espace et n'est pas annoncé comme un élément interactif.

Code conforme

function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <h3>
        <button
          aria-expanded={isOpen}
          aria-controls={contentId}
          onClick={() => setIsOpen(!isOpen)}
          className="accordion-header"
        >
          {title}
        </button>
      </h3>
      <div
        id={contentId}
        role="region"
        aria-labelledby={undefined}
        hidden={!isOpen}
      >
        {children}
      </div>
    </div>
  );
}

Le <button> est nativement focusable, activable au clavier et annoncé comme « bouton » par les lecteurs d'écran. aria-expanded indique l'état ouvert/fermé. aria-controls crée un lien programmatique entre le bouton et le contenu contrôlé. hidden masque le contenu au DOM et aux technologies d'assistance.

La règle d'or

Si c'est cliquable, c'est un <button> ou un <a>. Jamais un <div>, jamais un <span>. Un <a> si ça navigue vers une URL. Un <button> pour tout le reste (ouvrir un menu, déclencher une action, basculer un état).

Erreur 6 : le titre de page statique

Le problème

Le critère RGAA 8.5 exige que chaque page web ait un titre de page pertinent. Dans une SPA React, le <title> est souvent défini une seule fois dans le index.html et ne change jamais, quelle que soit la route.

Code conforme avec un hook personnalisé

import { useEffect } from 'react';

function useDocumentTitle(title) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = `${title} — MonSite`;

    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

// Utilisation dans chaque page
function ProductsPage() {
  useDocumentTitle('Nos produits');

  return (
    <div>
      <h1>Nos produits</h1>
      {/* ... */}
    </div>
  );
}

Si tu utilises un framework comme Next.js ou Remix, le titre de page est géré nativement. Avec React Router en mode SPA, le hook ci-dessus est la solution la plus simple.

Erreur 7 : les listes et la structure sémantique

Le problème

Le critère RGAA 9.3 exige que les listes soient correctement structurées. En React, les composants de navigation et de menus sont souvent composés de <div> imbriqués au lieu de listes sémantiques <ul> / <li>.

Code non conforme

function Navigation() {
  return (
    <div className="nav">
      <div className="nav-item"><a href="/">Accueil</a></div>
      <div className="nav-item"><a href="/produits">Produits</a></div>
      <div className="nav-item"><a href="/contact">Contact</a></div>
    </div>
  );
}

Code conforme

function Navigation() {
  return (
    <nav aria-label="Navigation principale">
      <ul>
        <li><a href="/">Accueil</a></li>
        <li><a href="/produits">Produits</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  );
}

Le <nav> crée un landmark ARIA détectable par les technologies d'assistance. Le aria-label le distingue des autres navigations (pied de page, fil d'Ariane). La liste <ul>/<li> permet au lecteur d'écran d'annoncer « liste de 3 éléments » — une information de structure précieuse pour l'utilisateur.

Checklist RGAA pour les développeurs React

Voici les vérifications à effectuer sur chaque composant :

Images (thématique 1)

  • Chaque <img> a un attribut alt pertinent ou alt="" si décorative
  • Les SVG interactifs ont un role="img" et un aria-label
  • Les images de fond CSS porteuses d'information ont une alternative textuelle

Formulaires (thématique 11)

  • Chaque champ a un <label> associé via htmlFor et id (utilise useId)
  • Les champs obligatoires sont identifiés (attribut required ou aria-required)
  • Les messages d'erreur sont associés au champ via aria-describedby
  • Les groupes de champs (radio, checkbox) utilisent <fieldset> et <legend>

Navigation (thématique 12)

  • Le focus est géré au changement de route (hook useRouteAnnouncer)
  • Le titre de page est mis à jour à chaque route (useDocumentTitle)
  • Un lien d'évitement « Aller au contenu principal » est présent
  • L'ordre de tabulation est logique (pas de tabIndex positif)

Scripts (thématique 7)

  • Tous les éléments interactifs sont des <button> ou <a>, pas des <div>
  • Les mises à jour dynamiques utilisent aria-live
  • Les modales piègent le focus (focus trap)
  • Les composants personnalisés ont les rôles ARIA appropriés

Structure (thématiques 8-9)

  • La hiérarchie des titres est cohérente (un seul <h1>, pas de saut de niveau)
  • Les landmarks sont utilisés (<nav>, <main>, <aside>, <footer>)
  • La langue de la page est définie (lang="fr" sur le <html>)

Tester ton application React avec WebConforme

Les vérifications manuelles c'est bien. L'automatisation c'est mieux. WebConforme te permet de scanner n'importe quelle URL — y compris les applications React en production — et de détecter automatiquement les violations RGAA.

Ce que WebConforme détecte dans une app React

  • Les images sans alt
  • Les champs de formulaire sans labels
  • Les contrastes de couleur insuffisants
  • Les titres manquants ou en désordre
  • Les liens sans intitulé accessible
  • Les attributs ARIA invalides ou mal utilisés
  • Les éléments interactifs non focusables

Comment l'utiliser

  1. Déploie ton application React (même sur un environnement de staging)
  2. Lance un scan sur l'URL de chaque page principale
  3. WebConforme génère un rapport avec, pour chaque violation, le code HTML fautif et le code corrigé
  4. Applique les corrections dans tes composants React
  5. Relance le scan pour vérifier

Pour aller plus loin, consulte notre guide sur les outils de test d'accessibilité gratuits en 2026.

Les bibliothèques React utiles pour l'accessibilité

eslint-plugin-jsx-a11y

Détecte les erreurs d'accessibilité directement dans ton éditeur de code. Obligatoire dans tout projet React sérieux.

@radix-ui/react-*

Radix UI fournit des composants headless (sans style) entièrement accessibles : Dialog, Dropdown Menu, Accordion, Tabs, Tooltip, etc. Tous gèrent correctement le focus, les rôles ARIA et la navigation clavier. Tu ajoutes ton propre CSS.

react-aria (Adobe)

Une collection de hooks React pour construire des composants accessibles from scratch. Plus bas niveau que Radix mais plus flexible. Développé par Adobe, très bien documenté.

@testing-library/react

La bibliothèque de test standard pour React. Son API est conçue autour de l'accessibilité : getByRole, getByLabelText, getByAltText. Si tu ne peux pas trouver un élément avec ces sélecteurs, c'est probablement que ton composant n'est pas accessible.

import { render, screen } from '@testing-library/react';

test('le formulaire de contact a des labels accessibles', () => {
  render(<ContactForm />);

  // Si ces sélecteurs échouent, les labels sont manquants ou mal associés
  expect(screen.getByLabelText('Nom')).toBeInTheDocument();
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: 'Envoyer' })).toBeInTheDocument();
});

Conclusion

L'accessibilité en React n'est pas un sujet à traiter à la fin du projet. C'est un ensemble de pratiques à intégrer dès le premier composant : utiliser les éléments HTML natifs, associer les labels aux champs, gérer le focus au changement de route, annoncer les mises à jour dynamiques avec aria-live, et tester avec des outils automatisés.

Les sept erreurs couvertes dans ce guide représentent la majorité des violations RGAA dans les applications React. Corrige-les et tu passeras de 30 % à 70 % de conformité automatisée. Pour les 30-40 % restants — les critères qualitatifs qui nécessitent un humain — un audit RGAA complet reste indispensable.

Lance un test gratuit sur WebConforme pour voir où en est ton application React. En 30 secondes, tu auras un diagnostic exploitable.

Votre site est-il conforme au RGAA ?

Scan gratuit en 30 secondes — aucune inscription requise.

Lancer un scan gratuit
#RGAA#React#accessibilité#développeur#JSX#aria#hooks#2026