Modèles d'accessibilité frontend
Modèles d'accessibilité pratiques pour React et Next.js. Couvre les problèmes les plus fréquemment soulevés lors des revues de code : étiquettes de formulaire manquantes, utilisation incorrecte d'ARIA, éléments interactifs non sémantiques et navigation clavier défaillante.
Quand activer cette compétence
- Création ou révision de composants de formulaire (
<input>,<select>,<textarea>) - Création d'éléments interactifs (modales, listes déroulantes, infobulles, onglets)
- Utilisation de
<div>ou<span>aveconClick - Ajout d'attributs
aria-*à un élément - Mise en œuvre de la navigation clavier ou de la gestion du focus
- Réception de retours d'accessibilité d'outils de revue de code (CodeRabbit, ESLint a11y)
- Création de composants devant prendre en charge les lecteurs d'écran
Accessibilité des formulaires
L'absence d'appariement htmlFor / id et les messages d'erreur déconnectés sont les problèmes les plus courants soulevés lors des revues de code.
Connexion des étiquettes
// MAUVAIS : l'étiquette n'a aucun lien avec le champ — les lecteurs d'écran ne peuvent pas les associer
<label>Email</label>
<input type="email" />
// BON : htmlFor correspond à l'id du champ
<label htmlFor="email">Email</label>
<input id="email" type="email" />
Champs obligatoires
// MAUVAIS : l'astérisque visuel seulement n'est pas transmis 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é au champ
<input id="email" type="email" />
<span className="error">Adresse e-mail invalide</span>
// BON : aria-describedby relie le champ à 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 = 'Email requis';
if (!password) newErrors.password = 'Mot de passe requis';
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 de 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, annoncé comme "bouton"
<button type="button" onClick={handleClick}>Soumettre</button>
// MAUVAIS : navigation non sémantique
<div onClick={() => navigate('/home')}>Accueil</div>
// BON : l'ancre prend en charge le clic droit, le clic central et la navigation clavier
<a href="/home">Accueil</a>
// MAUVAIS : hiérarchie de 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 ne suffit pas. Un mauvais ARIA est pire que pas d'ARIA.
aria-label vs aria-labelledby
// aria-label : étiquette textuelle en ligne — à utiliser lorsqu'aucun texte d'étiquette 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'une étiquette 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à de l'étiquette
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Supprimer le compte
</button>
<p id="delete-warning">Cette action est irréversible.</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 l'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 clavier
Chaque élément interactif doit être atteignable et utilisable uniquement avec le clavier.
Liste déroulante personnalisée
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 utilisateur change — en particulier pour les modales et les transitions de route.
Restauration du focus d'une modale
Cet exemple couvre le focus initial et la restauration. Pour un piège à focus complet (cycle Tab/Maj+Tab à l'intérieur de la modale), utilisez une bibliothèque comme
focus-trap-reactqui gère les cas limites 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) {
// Enregistre 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 non étiquetée
<img src="/icon.svg" />
// BON : image décorative masquée pour les lecteurs d'écran
<img src="/decoration.png" alt="" aria-hidden="true" />
// BON : image pertinente avec texte alternatif descriptif
<img src="/chart.png" alt="Le chiffre d'affaires mensuel a augmenté de 23 % de janvier à mars" />
// BON : bouton icône avec étiquette accessible
<button aria-label="Supprimer l'élément">
<TrashIcon aria-hidden="true" />
</button>
Mouvement réduit
Respectez les utilisateurs qui ont demandé un mouvement réduit 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-modèles
// MAUVAIS : onClick sur un élément non interactif sans support clavier
<div onClick={handleClick}>Cliquez ici</div>
// MAUVAIS : aria-label sur une div qui n'a pas de rôle
<div aria-label="Navigation">...</div>
// MAUVAIS : placeholder utilisé comme substitut d'étiquette
<input placeholder="Entrez 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 de 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 à une revue :
- Chaque
<input>,<select>et<textarea>a une<label>connectée viahtmlFor/id - Les messages d'erreur sont liés avec
aria-describedbyet marqués avecrole="alert" - Pas de
onClicksur<div>ou<span>sansrole,tabIndexetonKeyDown - Les boutons avec icône uniquement 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 cycle 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 et d'état Reactdesign-system— cohérence des tokens de design et des composantsmotion-foundationsetmotion-patterns: modèles d'animation avec considérations d'accessibilité

