Modèles d'accessibilité front-end
Modèles d'accessibilité pratiques pour React et Next.js. Couvre les problèmes les plus souvent signalés lors des revues de code : libellés de formulaire manquants, utilisation incorrecte d'ARIA, éléments interactifs non sémantiques et navigation au clavier cassée.
Quand activer
- Lors de la construction ou de la revue de composants de formulaire (
<input>,<select>,<textarea>) - Lors de la création d'éléments interactifs (modales, menus déroulants, infobulles, onglets)
- Lors de l'utilisation de
<div>ou<span>aveconClick - Lors de l'ajout d'attributs
aria-*à un élément - Lors de l'implémentation de la navigation au clavier ou de la gestion du focus
- Lors de la réception de retours d'accessibilité d'outils de revue de code (CodeRabbit, ESLint a11y)
- Lors de la construction de composants devant prendre en charge les lecteurs d'écran
Accessibilité des formulaires
L'absence de couplage htmlFor / id et les messages d'erreur déconnectés sont les problèmes les plus souvent signalés lors des revues de code.
Connexion du libellé
// MAUVAIS : le libellé n'a aucune connexion avec l'entrée — les lecteurs d'écran ne peuvent pas les associer
<label>Email</label>
<input type="email" />
// BON : htmlFor correspond à l'id de l'entrée
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Champs obligatoires
// MAUVAIS : l'astérisque visuel ne transmet rien aux lecteurs d'écran
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// BON : required active la validation native du navigateur ; aria-required le signale aux lecteurs d'écran
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
Messages d'erreur
// MAUVAIS : le texte d'erreur existe visuellement mais n'est pas lié à l'entrée
<input id="email" type="email" />
<span className="error">Adresse email invalide</span>
// BON : aria-describedby connecte l'entrée à son message d'erreur
// aria-invalid signale l'état invalide aux lecteurs d'écran
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
Formulaire accessible complet
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'L\'email est obligatoire';
if (!password) newErrors.password = 'Le mot de passe est obligatoire';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Mot de passe <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Se connecter</button>
</form>
);
}
HTML sémantique
Utilisez l'élément qui correspond à l'intention. Les lecteurs d'écran et les utilisateurs du clavier dépendent de la sémantique native.
// MAUVAIS : div n'a pas de rôle, pas de support clavier, pas de nom accessible
<div onClick={handleClick}>Soumettre</div>
// BON : button est focusable, s'active avec Entrée/Espace, s'annonce comme "bouton"
<button type="button" onClick={handleClick}>Soumettre</button>
// MAUVAIS : navigation non sémantique
<div onClick={() => navigate('/home')}>Accueil</div>
// BON : ancre prend en charge le clic droit, le clic milieu et la navigation au clavier
<a href="/home">Accueil</a>
// MAUVAIS : hiérarchie des titres sautée (h1 à h4)
<h1>Tableau de bord</h1>
<h4>Activité récente</h4>
// BON : niveaux de titres séquentiels
<h1>Tableau de bord</h1>
<h2>Activité récente</h2>
Attributs ARIA
N'utilisez ARIA que lorsque la sémantique HTML native est insuffisante. Un mauvais ARIA est pire que pas d'ARIA du tout.
aria-label vs aria-labelledby
// aria-label : libellé de chaîne en ligne — à utiliser lorsqu'aucun texte de libellé visible n'existe
<button aria-label="Fermer la modale">
<XIcon />
</button>
// aria-labelledby : référence le texte d'un autre élément — à utiliser lorsqu'un libellé visible existe
<section aria-labelledby="section-title">
<h2 id="section-title">Commandes récentes</h2>
{/* contenu */}
</section>
aria-describedby
// Fournit une description supplémentaire au-delà du libellé
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Supprimer le compte
</button>
<p id="delete-warning">Cette action ne peut pas être annulée.</p>
aria-live pour le contenu dynamique
// Utilisez aria-live pour annoncer le contenu qui se met à jour sans rechargement de page
// polite : attend que l'utilisateur termine son action en cours avant d'annoncer
// assertive : interrompt immédiatement — à utiliser uniquement pour les erreurs urgentes
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded et aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
Navigation au clavier
Chaque élément interactif doit pouvoir être atteint et utilisé uniquement avec le clavier.
Menu déroulant personnalisé
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
Gestion du focus
Le focus doit se déplacer logiquement lorsque l'état de l'interface change, en particulier pour les modales et les transitions de route.
Restauration du focus de la modale
Cet exemple couvre le focus initial et la restauration. Pour un piège à focus complet (cycle Tab/Maj+Tab dans la modale), utilisez une bibliothèque comme
focus-trap-reactqui gère les cas particuliers comme le contenu dynamique et les portails imbriqués.
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Sauvegarde l'élément actuellement focusé et déplace le focus dans la modale
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// Restaure le focus sur l'élément qui a ouvert la modale
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Fermer</button>
</div>
);
}
Images et icônes
// MAUVAIS : icône décorative annoncée comme image sans libellé
<img src="/icon.svg" />
// BON : image décorative masquée des lecteurs d'écran
<img src="/decoration.png" alt="" aria-hidden="true" />
// BON : image significative avec texte alternatif descriptif
<img src="/chart.png" alt="Le revenu mensuel a augmenté de 23% de janvier à mars" />
// BON : bouton icône avec libellé accessible
<button aria-label="Supprimer l'élément">
<TrashIcon aria-hidden="true" />
</button>
Réduction de mouvement
Respectez les utilisateurs qui ont demandé une réduction de mouvement dans les paramètres de leur système d'exploitation.
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Utilisation
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
Anti-patrons
// MAUVAIS : onClick sur un élément non interactif sans support clavier
<div onClick={handleClick}>Cliquez-moi</div>
// MAUVAIS : aria-label sur une div qui n'a pas de rôle
<div aria-label="Navigation">...</div>
// MAUVAIS : placeholder utilisé comme substitut de libellé
<input placeholder="Saisissez votre email" />
// MAUVAIS : tabIndex positif crée un ordre de tabulation imprévisible
<button tabIndex={3}>Soumettre</button>
// MAUVAIS : aria-hidden sur un élément focusable — les utilisateurs du clavier sont piégés
<button aria-hidden="true">Ouvrir</button>
// MAUVAIS : role="button" sur une div sans gestionnaire clavier
<div role="button" onClick={handleClick}>Soumettre</div>
// Manquant : tabIndex={0}, onKeyDown pour Entrée/Espace
Liste de vérification
Avant de soumettre un composant interactif à la revue :
- Chaque
<input>,<select>et<textarea>a un<label>connecté viahtmlFor/id - Les messages d'erreur sont liés avec
aria-describedbyet marquésrole="alert" - Aucun
onClicksur<div>ou<span>sansrole,tabIndexetonKeyDown - Les boutons avec uniquement une icône ont un
aria-label - Les images décoratives utilisent
alt=""etaria-hidden="true" - Les modales restaurent le focus à la fermeture (pour un piège à focus complet avec Tab/Maj+Tab, utilisez une bibliothèque comme
focus-trap-react) - Les mises à jour de contenu dynamique utilisent
aria-live -
prefers-reduced-motionest respecté pour les animations
Compétences associées
frontend-patterns— modèles généraux de composants React et d'étatdesign-system— cohérence des jetons de design et des composantsmotion-foundationsetmotion-patterns: modèles d'animation avec considérations d'accessibilité

