Aller au contenu principal
Technique

Accessibilité React : guide pratique RGAA

Rendez vos applications React accessibles et conformes au RGAA : composants, ARIA, focus, navigation clavier. Auditez votre app gratuitement.

14 min de lecture

React est le framework front-end le plus utilisé pour construire des interfaces web modernes. Mais les applications React, par leur nature dynamique et leurs rendus côté client, posent des défis spécifiques en matière d'accessibilité. Contenu chargé de manière asynchrone, navigation gérée en JavaScript, composants interactifs complexes : autant de situations qui peuvent rendre une application inutilisable pour les personnes en situation de handicap si elles ne sont pas traitées correctement.

Ce guide fournit des patterns concrets et des bonnes pratiques pour développer des applications React conformes au RGAA.

La sémantique HTML en JSX

Le premier levier d'accessibilité dans React est aussi le plus fondamental : utiliser les éléments HTML natifs pour leur sémantique, pas pour leur apparence visuelle.

Boutons vs divs cliquables

L'erreur la plus courante en React est de transformer des <div> ou des <span> en éléments interactifs :

// Incorrect : div cliquable
<div onClick={handleSubmit} className="btn">
  Envoyer
</div>

// Correct : bouton natif
<button onClick={handleSubmit} className="btn">
  Envoyer
</button>

Le <button> natif fournit gratuitement : le focus clavier, l'activation via Entrée et Espace, le rôle button pour les technologies d'assistance, et la gestion native du formulaire. Un <div> cliquable ne fournit rien de tout cela.

Pour comprendre quand utiliser un bouton versus un lien, consultez notre outil bouton vs lien.

Liens vs boutons

La distinction est sémantique et a un impact direct sur l'accessibilité :

  • <a href="..."> — Navigation vers une autre page ou une ancre. Le lecteur d'écran annonce "lien".
  • <button> — Action sur la page courante (ouvrir un menu, soumettre un formulaire, déclencher un traitement). Le lecteur d'écran annonce "bouton".
// Navigation : utiliser un lien
<Link href="/dashboard">Accéder au tableau de bord</Link>

// Action : utiliser un bouton
<button onClick={() => setIsOpen(true)}>Ouvrir le menu</button>

Éléments de structure

React ne vous empêche pas d'utiliser les éléments sémantiques HTML5. Structurez vos composants avec :

function PageLayout({ children }) {
  return (
    <>
      <header>
        <nav aria-label="Navigation principale">
          {/* Menu */}
        </nav>
      </header>
      <main id="main-content">
        {children}
      </main>
      <footer>
        {/* Pied de page */}
      </footer>
    </>
  );
}

Ces landmarks (<header>, <nav>, <main>, <footer>) permettent aux utilisateurs de lecteurs d'écran de naviguer rapidement entre les grandes zones de la page.

Les attributs ARIA en React

La règle d'or

Avant d'utiliser ARIA, rappelez-vous la première règle d'ARIA : "Si vous pouvez utiliser un élément HTML natif avec la sémantique et le comportement souhaités, faites-le plutôt que d'ajouter un rôle ARIA." ARIA ne doit intervenir que lorsque HTML natif ne suffit pas.

Syntaxe JSX

En JSX, les attributs ARIA s'écrivent exactement comme en HTML, avec le tiret :

<button aria-expanded={isOpen} aria-controls="menu-dropdown">
  Menu
</button>
<ul id="menu-dropdown" role="menu" aria-hidden={!isOpen}>
  <li role="menuitem">Option 1</li>
  <li role="menuitem">Option 2</li>
</ul>

Notez que aria-expanded et aria-hidden acceptent des valeurs booléennes JSX (sans guillemets), qui seront correctement rendues en "true" ou "false" dans le HTML.

Les attributs ARIA les plus utiles

aria-label — Fournit un nom accessible quand le texte visible ne suffit pas :

<button aria-label="Fermer la modale" onClick={handleClose}>
  <XIcon />
</button>

aria-describedby — Associe une description complémentaire :

<input
  type="password"
  id="password"
  aria-describedby="password-help"
/>
<p id="password-help">
  Minimum 8 caractères, une majuscule et un chiffre.
</p>

aria-live — Annonce les changements dynamiques de contenu :

<div aria-live="polite" role="status">
  {message && <p>{message}</p>}
</div>

aria-current — Indique l'élément courant dans une navigation :

<nav aria-label="Navigation principale">
  {links.map(link => (
    <a
      key={link.href}
      href={link.href}
      aria-current={pathname === link.href ? "page" : undefined}
    >
      {link.label}
    </a>
  ))}
</nav>

Pour générer les bons attributs ARIA selon le pattern d'interface, utilisez notre générateur ARIA.

Gestion du focus

La gestion du focus est l'un des aspects les plus critiques et les plus négligés dans les applications React. Le RGAA exige que tous les composants interactifs soient atteignables et utilisables au clavier (thématique 7 — Scripts).

useRef pour le focus programmatique

Quand le contenu change dynamiquement (ouverture d'une modale, chargement d'un résultat, navigation SPA), déplacez le focus vers le nouvel élément pertinent :

import { useRef, useEffect } from "react";

function SearchResults({ results, isLoading }) {
  const headingRef = useRef(null);

  useEffect(() => {
    if (results.length > 0 && headingRef.current) {
      headingRef.current.focus();
    }
  }, [results]);

  if (isLoading) return <p aria-live="polite">Chargement...</p>;

  return (
    <section aria-labelledby="results-heading">
      <h2
        id="results-heading"
        ref={headingRef}
        tabIndex={-1}
      >
        {results.length} résultat{results.length > 1 ? "s" : ""} trouvé{results.length > 1 ? "s" : ""}
      </h2>
      <ul>
        {results.map(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </section>
  );
}

Le tabIndex={-1} permet de donner le focus programmatiquement à un élément non interactif sans l'ajouter à l'ordre de tabulation naturel.

Le piège du focus (focus trap)

Pour les composants modaux (dialogues, drawers, popovers), le focus doit être piégé à l'intérieur du composant : l'utilisateur ne doit pas pouvoir tabuler vers le contenu en arrière-plan. Voici un hook personnalisé :

import { useEffect, useRef } from "react";

function useFocusTrap(isActive) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableSelector = [
      "a[href]",
      "button:not([disabled])",
      "input:not([disabled])",
      "select:not([disabled])",
      "textarea:not([disabled])",
      '[tabindex]:not([tabindex="-1"])',
    ].join(", ");

    const focusableElements = container.querySelectorAll(focusableSelector);
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Focus le premier élément à l'ouverture
    firstElement?.focus();

    function handleKeyDown(e) {
      if (e.key !== "Tab") return;

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    }

    container.addEventListener("keydown", handleKeyDown);
    return () => container.removeEventListener("keydown", handleKeyDown);
  }, [isActive]);

  return containerRef;
}

Restauration du focus

Quand un composant modal se ferme, le focus doit revenir à l'élément qui l'a déclenché :

function Modal({ isOpen, onClose, children }) {
  const triggerRef = useRef(null);
  const containerRef = useFocusTrap(isOpen);

  useEffect(() => {
    if (isOpen) {
      triggerRef.current = document.activeElement;
    } else if (triggerRef.current) {
      triggerRef.current.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={containerRef}
    >
      <button onClick={onClose} aria-label="Fermer">
        &times;
      </button>
      {children}
    </div>
  );
}

Navigation clavier

Ordre de tabulation logique

L'ordre de tabulation doit suivre l'ordre visuel de lecture (de gauche à droite, de haut en bas en français). En React, cet ordre correspond naturellement à l'ordre des éléments dans le JSX. Évitez de le modifier avec tabIndex positif (1, 2, 3...) — c'est une pratique fortement déconseillée qui crée des incohérences.

Gestion des raccourcis clavier

Pour les composants complexes, implémentez les raccourcis clavier attendus :

function TabPanel({ tabs, activeTab, onTabChange }) {
  function handleKeyDown(e, index) {
    let newIndex;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    onTabChange(newIndex);
  }

  return (
    <div role="tablist" aria-label="Sections">
      {tabs.map((tab, index) => (
        <button
          key={tab.id}
          role="tab"
          id={`tab-${tab.id}`}
          aria-selected={activeTab === index}
          aria-controls={`panel-${tab.id}`}
          tabIndex={activeTab === index ? 0 : -1}
          onClick={() => onTabChange(index)}
          onKeyDown={(e) => handleKeyDown(e, index)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

Ce pattern roving tabindex est recommandé par le WAI-ARIA Authoring Practices : un seul onglet est dans l'ordre de tabulation (tabIndex={0}), les flèches permettent de naviguer entre les onglets.

Gestion de la touche Échap

Tous les composants superposés (modales, menus déroulants, popovers) doivent se fermer avec la touche Échap :

useEffect(() => {
  function handleEscape(e) {
    if (e.key === "Escape" && isOpen) {
      onClose();
    }
  }

  document.addEventListener("keydown", handleEscape);
  return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);

Formulaires accessibles en React

Les formulaires sont un point critique du RGAA (thématique 11). En React, plusieurs patterns assurent leur accessibilité.

Étiquetage des champs

Chaque champ doit être associé à une étiquette visible :

function FormField({ id, label, error, ...inputProps }) {
  const errorId = `${id}-error`;
  const descriptionId = inputProps["aria-describedby"];

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={
          [error && errorId, descriptionId].filter(Boolean).join(" ") || undefined
        }
        {...inputProps}
      />
      {error && (
        <p id={errorId} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Notez l'utilisation de htmlFor (au lieu de for en HTML) qui est la syntaxe JSX pour l'attribut for de <label>.

Validation et messages d'erreur

Les messages d'erreur doivent être annoncés automatiquement aux technologies d'assistance :

function ContactForm() {
  const [errors, setErrors] = useState({});
  const [status, setStatus] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    const newErrors = validate(formData);

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // Focus le premier champ en erreur
      const firstErrorField = document.getElementById(
        Object.keys(newErrors)[0]
      );
      firstErrorField?.focus();
      return;
    }

    setStatus("sending");
    await sendForm(formData);
    setStatus("success");
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <FormField
        id="name"
        label="Nom complet"
        type="text"
        required
        error={errors.name}
      />
      <FormField
        id="email"
        label="Adresse e-mail"
        type="email"
        required
        error={errors.email}
      />
      <FormField
        id="message"
        label="Votre message"
        as="textarea"
        required
        error={errors.message}
      />

      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Envoi en cours..." : "Envoyer"}
      </button>

      <div aria-live="polite" role="status">
        {status === "success" && (
          <p>Message envoyé avec succès.</p>
        )}
      </div>
    </form>
  );
}

Points clés :

  • noValidate sur le <form> pour gérer la validation côté React et fournir des messages personnalisés et accessibles
  • Focus déplacé vers le premier champ en erreur
  • role="alert" sur les messages d'erreur pour annonce immédiate
  • aria-live="polite" pour le message de succès
  • aria-invalid sur les champs en erreur

Autocomplétion

L'attribut autocomplete aide les utilisateurs et les technologies d'assistance à remplir les formulaires. Le RGAA (critère 11.13) exige son utilisation pour les champs contenant des données personnelles :

<input type="text" autoComplete="given-name" />
<input type="text" autoComplete="family-name" />
<input type="email" autoComplete="email" />
<input type="tel" autoComplete="tel" />

Pour générer les bonnes valeurs d'autocomplétion, utilisez notre générateur autocomplete.

Annonces dynamiques et zones live

Les applications React mettent à jour le DOM de manière dynamique. Sans signalement approprié, ces changements sont invisibles aux lecteurs d'écran.

Composant d'annonce

Créez un composant dédié aux annonces :

function LiveAnnouncer({ message, priority = "polite" }) {
  return (
    <div
      role={priority === "assertive" ? "alert" : "status"}
      aria-live={priority}
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

La classe sr-only (screen reader only) masque visuellement le contenu tout en le gardant accessible aux technologies d'assistance :

.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;
}

Navigation SPA et annonce de page

Dans une application React monopage (SPA), les changements de page ne déclenchent pas de rechargement du navigateur. Les lecteurs d'écran ne sont donc pas informés du changement de contexte. Avec Next.js (le framework utilisé par RGAA Scanner), cette gestion est partiellement intégrée, mais vérifiez que :

  • Le titre de la page (<title>) est mis à jour à chaque navigation
  • Le focus est déplacé vers le contenu principal ou le titre de la nouvelle page
  • Un message d'annonce signale le changement de page
// Dans un layout Next.js ou un composant route
useEffect(() => {
  // Annonce le titre de la page au lecteur d'écran
  document.title = `${pageTitle} | RGAA Scanner`;

  // Déplace le focus vers le contenu principal
  const main = document.getElementById("main-content");
  if (main) {
    main.tabIndex = -1;
    main.focus();
  }
}, [pageTitle]);

Patterns de composants accessibles

Modale (Dialog)

function Dialog({ isOpen, onClose, title, children }) {
  const dialogRef = useFocusTrap(isOpen);

  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = "hidden";
    }
    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop */}
      <div
        className="dialog-backdrop"
        aria-hidden="true"
        onClick={onClose}
      />
      {/* Dialog */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialog-title"
      >
        <h2 id="dialog-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="Fermer la boîte de dialogue">
          &times;
        </button>
      </div>
    </>
  );
}

Points essentiels :

  • role="dialog" et aria-modal="true"
  • Titre associé via aria-labelledby
  • Focus trap actif
  • Fermeture par Échap et par bouton
  • Backdrop non interactif (aria-hidden="true")

Menu déroulant (Dropdown)

function Dropdown({ label, items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef(null);
  const buttonRef = useRef(null);

  function handleButtonKeyDown(e) {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      setIsOpen(true);
      setActiveIndex(0);
    }
  }

  function handleMenuKeyDown(e) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex(prev => Math.min(prev + 1, items.length - 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex(prev => Math.max(prev - 1, 0));
        break;
      case "Escape":
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
      case "Home":
        e.preventDefault();
        setActiveIndex(0);
        break;
      case "End":
        e.preventDefault();
        setActiveIndex(items.length - 1);
        break;
    }
  }

  return (
    <div>
      <button
        ref={buttonRef}
        aria-expanded={isOpen}
        aria-haspopup="true"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleButtonKeyDown}
      >
        {label}
      </button>
      {isOpen && (
        <ul
          ref={menuRef}
          role="menu"
          onKeyDown={handleMenuKeyDown}
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={activeIndex === index ? 0 : -1}
              ref={el => {
                if (activeIndex === index) el?.focus();
              }}
              onClick={() => {
                item.action();
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Tester l'accessibilité en React

Tests automatisés avec axe-core

axe-core est le moteur d'analyse d'accessibilité le plus utilisé. C'est d'ailleurs le moteur qui propulse notre scanner RGAA. Intégrez-le dans vos tests :

// Avec jest et @axe-core/react
import { axe, toHaveNoViolations } from "jest-axe";
import { render } from "@testing-library/react";

expect.extend(toHaveNoViolations);

test("le formulaire de contact est accessible", async () => {
  const { container } = render(<ContactForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Tests avec React Testing Library

React Testing Library encourage les tests orientés accessibilité grâce à ses sélecteurs basés sur les rôles ARIA :

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("la modale est accessible au clavier", async () => {
  const user = userEvent.setup();
  render(<ModalDemo />);

  // Ouvrir la modale
  await user.click(screen.getByRole("button", { name: "Ouvrir" }));

  // Vérifier que la modale est affichée
  expect(screen.getByRole("dialog")).toBeInTheDocument();

  // Vérifier que le titre est annoncé
  expect(
    screen.getByRole("heading", { name: "Titre de la modale" })
  ).toBeInTheDocument();

  // Fermer avec Échap
  await user.keyboard("{Escape}");
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

  // Vérifier que le focus est revenu au bouton
  expect(screen.getByRole("button", { name: "Ouvrir" })).toHaveFocus();
});

Les sélecteurs getByRole, getByLabelText et getByAltText garantissent que vos composants exposent la bonne sémantique. Si un sélecteur par rôle ne fonctionne pas, c'est probablement un problème d'accessibilité dans votre composant.

Audit en développement

Pour détecter les problèmes d'accessibilité pendant le développement, utilisez @axe-core/react en mode développement :

// Dans votre fichier d'entrée (main.jsx ou app.jsx)
if (process.env.NODE_ENV === "development") {
  import("@axe-core/react").then((axe) => {
    axe.default(React, ReactDOM, 1000);
  });
}

Cet outil affiche les violations d'accessibilité directement dans la console du navigateur pendant le développement.

Checklist React RGAA

Avant chaque mise en production, vérifiez :

  • Tous les éléments interactifs utilisent les balises HTML natives appropriées (<button>, <a>, <input>)
  • Les attributs ARIA sont utilisés uniquement quand HTML natif ne suffit pas
  • Les images ont un alt pertinent ou vide (décoratif)
  • Le focus est géré lors des changements de contexte (modales, navigation SPA, chargement dynamique)
  • Le focus trap est actif pour les composants modaux
  • La touche Échap ferme les composants superposés
  • Les formulaires ont des étiquettes visibles et associées
  • Les erreurs de formulaire sont annoncées et le focus est dirigé
  • Les contenus dynamiques utilisent aria-live pour signaler les changements
  • La navigation clavier fonctionne pour tous les composants interactifs
  • L'ordre de tabulation est cohérent avec l'ordre visuel
  • Les tests automatisés (axe-core) passent sans violation
  • Les titres suivent une hiérarchie logique (vérifiez avec notre validateur de titres)

Conclusion

L'accessibilité en React n'est pas un ajout cosmétique. C'est un ensemble de pratiques d'ingénierie qui doivent être intégrées dès la conception des composants. Les principes fondamentaux — sémantique HTML, gestion du focus, navigation clavier, attributs ARIA — restent les mêmes que pour tout développement web, mais React apporte des outils (hooks, refs, composants réutilisables) qui facilitent leur mise en oeuvre de manière systématique.

Commencez par auditer votre application existante avec notre scanner RGAA pour identifier les problèmes prioritaires, puis refactorisez vos composants en suivant les patterns décrits dans ce guide. Pour une référence technique complète avec des exemples de code détaillés, consultez notre guide accessibilité React. Chaque composant rendu accessible bénéficie à l'ensemble de votre application et à tous vos utilisateurs. Et n'oubliez pas : l'accessibilité est une obligation légale dont le non-respect expose à des sanctions financières concrètes.

ReactaccessibilitéRGAAdéveloppementARIA

Questions fréquentes

React est-il accessible par défaut ?

React ne pose pas de problème d'accessibilité en soi, mais il ne la garantit pas non plus. Les principaux risques viennent de la gestion du DOM virtuel (focus perdu après re-render), des composants personnalisés sans sémantique HTML, et de l'absence de gestion des annonces pour les lecteurs d'écran.

Comment gérer le focus dans une SPA React ?

Utilisez useRef pour cibler les éléments, useEffect pour déplacer le focus après une navigation, et un composant de route announcer pour informer les lecteurs d'écran des changements de page. React Router ne gère pas le focus automatiquement.

Quels outils pour tester l'accessibilité d'une app React ?

Utilisez eslint-plugin-jsx-a11y en développement, @axe-core/react pour les tests automatisés dans le navigateur, et jest-axe ou @testing-library/jest-dom pour les tests unitaires. Complétez avec un scan RGAA automatisé.

Testez l'accessibilité de votre site

Analysez votre site en quelques secondes avec notre scanner RGAA automatisé.

Lancer un scan gratuit