Aller au contenu principal
MEWA STUDIO

Accessibilité des interactions : vestibulaire, daltonisme, navigation clavier

Publié le 6 mars 2026|11 min de lecture
accessibilitéinteractionUX

Découvrez comment rendre vos interactions accessibles sans sacrifier l'expérience. Solutions concrètes pour vestibulaire, daltonisme et navigation clavier. Impact business réel.

Câbles colorés entrelacés sur fond bleu violet foncé, symbolisant les connexions entre accessibilité et interactions web

Ouvrez un site e-commerce primé aux Awwwards. Dès le premier scroll, une animation parallax plein écran se déclenche : le produit phare défile en 3D, des particules explosent, le texte glisse sur trois axes. Pour un utilisateur atteint d'un trouble vestibulaire, la nausée monte en deux secondes. L'onglet se ferme. Il ne reviendra pas.

Même site. Un utilisateur daltonien protanope cherche le bouton "Ajouter au panier". Le bouton est un rectangle rouge sur fond blanc. Pour lui, c'est un rectangle gris terne qu'il confond avec un élément désactivé. Il doute, hésite, abandonne.

Même site. Un utilisateur navigue au clavier, paralysie partielle de la main droite. Il appuie sur Tab : rien ne se passe visuellement. Le focus existe quelque part dans le DOM, mais l'outline a été supprimé par un développeur qui trouvait ça "moche". Navigation à l'aveugle dans une interface inutilisable.

Trois profils. Trois exclusions. Un seul site. Et ce site, c'est peut-être le vôtre.

Le coût réel de l'exclusion

On parle souvent d'accessibilité comme d'une obligation légale ou d'un geste éthique. C'est les deux. Mais c'est aussi, et surtout, un choix business.

Les chiffres :
- 16% de la population mondiale, soit 1,3 milliard de personnes, vit avec un handicap significatif (source : OMS (opens in a new tab))
- 8% des hommes et 0.5% des femmes sont daltoniens (source : National Eye Institute (opens in a new tab))
- 2-3% de la population souffre de troubles vestibulaires diagnostiqués (source : NIDCD (opens in a new tab))
- 1 utilisateur sur 4 ressent un malaise lors d'un scroll avec parallax (source : W3C WCAG 2.1 (opens in a new tab))

Mais au-delà des stats, il y a un phénomène que les entreprises sous-estiment : les utilisateurs concernés par l'accessibilité sont parmi les plus fidèles. Quand quelqu'un trouve un site qui respecte ses besoins, il y revient, le recommande, devient ambassadeur. Parce que ces sites sont rares.

L'inverse est tout aussi vrai. Une étude de Click-Away Pound (opens in a new tab) estime que les entreprises britanniques perdent 17,1 milliards de livres par an en abandonnant les utilisateurs handicapés qui "cliquent ailleurs". En France, le ratio est proportionnel.

L'accessibilité n'est pas un supplément de conscience. C'est un avantage concurrentiel structurel.

Le mouvement : quand le design rend malade

Ce que vit un utilisateur vestibulaire sur votre site a un nom : le "cybersickness" (mal du cyber). C'est un trouble vestibulaire déclenché par un conflit entre ce que les yeux voient (mouvement) et ce que l'oreille interne ressent (immobilité). Les symptômes ? Nausée, vertiges, maux de tête, et dans les cas sévères, impossibilité de continuer à utiliser un écran pendant des heures.

Les animations les plus à risque :
- Parallax : décalage de vitesse entre les couches, le cerveau ne sait plus quelle surface est "réelle"
- Zoom rapide : simulation de profondeur que l'oreille interne contredit
- Animations multi-axes : un élément qui se déplace en X, Y et Z simultanément crée une surcharge sensorielle
- Auto-play vidéo avec mouvement : mouvement non sollicité dans la vision périphérique

La solution : prefers-reduced-motion

Tous les OS modernes permettent aux utilisateurs de demander la réduction des mouvements. macOS, iOS, Windows, Android : chacun expose cette préférence. Et le web dispose d'une media query pour la lire :

css
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

L'approche nucléaire (efficace mais brutale)

Ça fonctionne. Mais c'est l'équivalent d'éteindre toutes les lumières pour résoudre un problème d'éclairage. Pour un site premium, il faut être plus fin : remplacer les animations, pas les supprimer.

L'approche premium : un hook React réutilisable

Au lieu de gérer prefers-reduced-motion dans chaque composant, centralisez la logique dans un hook :

tsx
import { useState, useEffect } from 'react';

export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(query.matches);

    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    query.addEventListener('change', handler);
    return () => query.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

useReducedMotion.ts

Puis dans vos composants, l'animation se transforme au lieu de disparaître :

tsx
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/useReducedMotion';

export const ProductCard = ({ product }) => {
  const reducedMotion = useReducedMotion();

  return (
    <motion.article
      initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 30 }}
      whileInView={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
      transition={reducedMotion
        ? { duration: 0.15 }          // fade instantané
        : { duration: 0.6, ease: 'easeOut' }  // slide + fade
      }
      viewport={{ once: true }}
    >
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
    </motion.article>
  );
};

ProductCard.tsx, animation adaptative

L'utilisateur standard voit un slide-up fluide. L'utilisateur vestibulaire voit un fade discret. Les deux vivent une expérience soignée. La différence ? 15 lignes de code.

La couleur : quand le design rend invisible

Imaginez un formulaire de contact. L'utilisateur soumet ses informations. Le bouton passe du bleu au vert, avec le texte "Envoyé !". Pour vous, c'est clair. Pour un daltonien protanope, le bouton est passé d'un bleu-gris à un brun-jaune terne. Le mot "Envoyé" est là, mais la couleur, censée renforcer le message, ne communique rien.

C'est le cas le plus courant. Mais il y a pire : les interfaces où la couleur est le seul vecteur d'information. Un badge "en ligne" vert sans texte. Un graphique avec 5 courbes différenciées uniquement par la teinte. Un champ de formulaire qui passe au rouge pour indiquer une erreur, sans message ni icône.

Les types de daltonisme

TypeCe qui est affectéCe que l'utilisateur voitFréquence
ProtanopiePerception du rougeRouge => brun sombre / gris. Le vert et le rouge se confondent.1 % des hommes, 0.01 % des femmes
DeutéranopiePerception du vertVert => brun / ocre. Rouge et vert sont quasi identiques.1 % des hommes, 0.01 % des femmes
TritanopiePerception du bleuBleu => rose/gris. Jaune et bleu se confondent.0.01 % (très rare)

85% des daltoniens sont protanopes ou deutéranopes. La conséquence directe : le duo rouge/vert, que tout le monde utilise pour succès/erreur, est le pire choix possible pour 1 utilisateur masculin sur 12.

La règle absolue : jamais de couleur seule

Chaque information transmise par la couleur doit être doublée par au moins un autre canal :

  • Icône : un check pour validé, un triangle alerte pour erreur, un loader pour en cours
  • Texte : "Envoyé avec succès" pas juste un changement de couleur
  • Forme / pattern : bordure pleine vs pointillée, fond plein vs hachuré
  • Position : un message d'erreur apparaît sous le champ, pas juste un changement de bordure

Code : un bouton de formulaire accessible

tsx
// La couleur est le seul indicateur d'état
<button style={{ backgroundColor: success ? 'green' : 'red' }}>
  Envoyer
</button>

Ce qu'on voit partout (insuffisant)

tsx
type Status = 'idle' | 'loading' | 'success' | 'error';

const SubmitButton = ({ status, errorMessage }: {
  status: Status;
  errorMessage?: string;
}) => (
  <div>
    <button
      disabled={status === 'loading'}
      aria-describedby={status === 'error' ? 'form-error' : undefined}
      className={clsx(
        'submit-btn',
        status === 'success' && 'submit-btn--success',
        status === 'error' && 'submit-btn--error',
      )}
    >
      {status === 'loading' && <Spinner aria-hidden="true" />}
      {status === 'success' && <CheckIcon aria-hidden="true" />}
      {status === 'error' && <AlertIcon aria-hidden="true" />}
      <span>
        {status === 'idle' && 'Envoyer'}
        {status === 'loading' && 'Envoi en cours...'}
        {status === 'success' && 'Envoyé !'}
        {status === 'error' && 'Erreur, réessayer'}
      </span>
    </button>
    {status === 'error' && errorMessage && (
      <p id="form-error" role="alert" className="error-message">
        {errorMessage}
      </p>
    )}
  </div>
);

Ce qu'il faut faire (multi-canal)

Ici, chaque état est communiqué par quatre canaux simultanés : couleur (CSS), icône, texte et message contextuel. Lisible par 100% des utilisateurs, quel que soit leur mode de perception.

Le contraste : viser AAA, pas AA

WCAG définit deux niveaux de contraste :
- AA (minimum légal) : 4.5:1 pour le texte courant, 3:1 pour le texte grand
- AAA (recommandé) : 7:1 pour le texte courant, 4.5:1 pour le texte grand
Pour un site qui se veut premium, viser AA c'est viser la moyenne. Visez AAA. Le contraste élevé ne bénéficie pas qu'aux daltoniens, il améliore la lisibilité en plein soleil, sur un écran bas de gamme, ou simplement pour des yeux fatigués en fin de journée.

Le contrôle : quand le design rend muet

5 à 10% de vos utilisateurs naviguent au clavier. C'est le cas des : personnes avec handicap moteur, utilisateurs de lecteurs d'écran, power users qui préfèrent le clavier et développeurs qui testent sans souris. Pour tous ces utilisateurs, votre interface doit être entièrement opérable sans pointeur.

Les trois erreurs les plus fréquentes :

Erreur 1 : des <div> cliquables au lieu de <button>

tsx
<div onClick={() => addToCart(product)}>Ajouter au panier</div>

Invisible au clavier

tsx
<button onClick={() => addToCart(product)}>Ajouter au panier</button>

Accessible nativement

Un <button> est focusable, activable au clavier (Enter et Space) et annoncé comme "bouton" par les lecteurs d'écran. Un <div> n'est rien de tout ça. Si votre élément déclenche une action, c'est un <button>. Point.

Erreur 2 : le focus invisible

css
/* Vu dans 60% des projets. Ne faites JAMAIS ça. */
*:focus {
  outline: none;
}

Le crime le plus courant en CSS

Supprimer l'outline de focus, c'est retirer les panneaux de signalisation d'une route. L'utilisateur au clavier ne sait plus où il est. La solution ? Stylisez le focus au lieu de le cacher :

css
/* :focus-visible s'active uniquement au clavier, pas au clic souris */
*:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 3px;
  border-radius: 2px;
}

Un focus visible et élégant

:focus-visible est la solution idéale : il affiche l'outline quand l'utilisateur navigue au clavier, mais le masque lors d'un clic souris. Vous gardez une interface propre pour les utilisateurs de souris et un repère clair pour les utilisateurs de clavier.

Erreur 3 : le focus qui s'évade des modales

Une modale s'ouvre. L'utilisateur appuie sur Tab. Le focus traverse la modale... puis continue derrière, dans la page en arrière-plan. L'utilisateur au clavier est perdu dans un contenu qu'il ne voit même pas. Le focus doit être piégé (focus trap) dans la modale :

tsx
import { useEffect, useRef, useCallback } from 'react';

const Modal = ({ isOpen, onClose, children }) => {
  const modalRef = useRef<HTMLDivElement>(null);

  const trapFocus = useCallback((e: KeyboardEvent) => {
    if (e.key === 'Escape') return onClose();
    if (e.key !== 'Tab') return;

    const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (!focusable?.length) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }, [onClose]);

  useEffect(() => {
    if (!isOpen) return;
    const previouslyFocused = document.activeElement as HTMLElement;
    modalRef.current?.querySelector<HTMLElement>('button, [href], input')?.focus();
    document.addEventListener('keydown', trapFocus);

    return () => {
      document.removeEventListener('keydown', trapFocus);
      previouslyFocused?.focus(); // restaure le focus à la fermeture
    };
  }, [isOpen, trapFocus]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  );
};

Modale avec focus trap

Trois détails importants dans ce code :
1.Le focus est donné au premier élément interactif à l'ouverture
2.Tab boucle entre le premier et le dernier élément focusable
3.Le focus est restauré sur l'élément d'origine à la fermeture, l'utilisateur retrouve sa position

Le silence : quand le design oublie de parler

Il y a un problème que même les développeurs sensibilisés à l'accessibilité oublient : les changements dynamiques. Votre panier affiche un toast "Article ajouté". Votre formulaire affiche "Envoyé avec succès". Votre dashboard met à jour un compteur en temps réel.

Visuellement, ces changements sont évidents. Mais pour un utilisateur de lecteur d'écran, le contenu qui apparaît dans le DOM ne sera jamais annoncé sauf si vous le demandez explicitement. C'est le rôle d'aria-live.

aria-live : donner une voix à vos interactions

tsx
const Toast = ({ message, type }: {
  message: string;
  type: 'success' | 'error' | 'info';
}) => (
  <div
    role={type === 'error' ? 'alert' : 'status'}
    aria-live={type === 'error' ? 'assertive' : 'polite'}
    aria-atomic="true"
    className={`toast toast--${type}`}
  >
    {type === 'success' && <CheckIcon aria-hidden="true" />}
    {type === 'error' && <AlertIcon aria-hidden="true" />}
    {type === 'info' && <InfoIcon aria-hidden="true" />}
    <span>{message}</span>
  </div>
);

Toast de notification accessible

La distinction entre les deux modes est cruciale :

ModeComportementCas d'usage
aria-live="polite"Attend que le lecteur d'écran finisse sa phrase en cours, puis annonce le changementToast de confirmation, mise à jour de compteur, message de succès
aria-live="assertive"Interrompt immédiatement le lecteur d'écran pour annoncer le changementErreur de formulaire, alerte de sécurité, session expirée

role="status" est un raccourci sémantique pour aria-live="polite" et role="alert" pour aria-live="assertive". Utilisez les rôles quand c'est possible, c'est plus lisible et plus explicite.

Piège courant : le conteneur doit exister AVANT le contenu

Un piège fréquent avec aria-live : si vous injectez le conteneur et son contenu en même temps dans le DOM, certains lecteurs d'écran ne l'annoncent pas. Le conteneur aria-live doit être présent dans le DOM avant que le contenu change :

tsx
// Le div aria-live est TOUJOURS dans le DOM (même vide)
// Seul le message change quand un événement se produit
const [notification, setNotification] = useState('');

return (
  <>
    <div aria-live="polite" aria-atomic="true" className="sr-only">
      {notification}
    </div>
    <button onClick={() => {
      addToCart(product);
      setNotification(`${product.name} ajouté au panier`);
    }}>
      Ajouter au panier
    </button>
  </>
);

Le conteneur existe toujours, le contenu change

Sans aria-live, vos micro-interactions les plus soignées (le toast de confirmation, le badge mis à jour, le compteur animé) n'existent pas pour les utilisateurs de technologies d'assistance. Elles sont muettes.

Tout assembler : le composant qui intègre tout

En pratique, ces quatre dimensions (mouvement, couleur, clavier, annonces) ne sont pas des chantiers séparés. Elles se combinent dans chaque composant. Voici à quoi ressemble une carte produit qui les intègre toutes :

tsx
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/useReducedMotion';
import { useState } from 'react';

export const ProductCard = ({ product }) => {
  const reducedMotion = useReducedMotion();
  const [status, setStatus] = useState<'idle' | 'added'>('idle');
  const [announcement, setAnnouncement] = useState('');

  const handleAdd = () => {
    addToCart(product);
    setStatus('added');
    setAnnouncement(`${product.name} ajouté au panier`);
    setTimeout(() => setStatus('idle'), 2000);
  };

  return (
    <motion.article
      initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
      whileInView={reducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
      transition={{ duration: reducedMotion ? 0.1 : 0.5 }}
      viewport={{ once: true }}
    >
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">{product.price}</p>

      <button
        onClick={handleAdd}
        className={status === 'added' ? 'btn--success' : 'btn--primary'}
      >
        {status === 'added' && <CheckIcon aria-hidden="true" />}
        <span>{status === 'added' ? 'Ajouté !' : 'Ajouter au panier'}</span>
      </button>

      {/* Annonce pour les lecteurs d'écran */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {announcement}
      </div>
    </motion.article>
  );
};

ProductCard accessible : mouvement, couleur, clavier, annonces

Ce composant :
- Mouvement : l'animation s'adapte via useReducedMotion, slide ou fade selon la préférence
- Couleur : le changement d'état du bouton utilise icône + texte + couleur (jamais couleur seule)
- Clavier : c'est un vrai <button>, focusable et activable nativement
- Annonce : aria-live informe le lecteur d'écran que l'article a été ajouté

Aucun compromis visuel. Aucune complexité excessive. Quatre dimensions d'accessibilité dans un composant propre.

Checklist avant mise en production

  • Mouvement : activer "Réduire les animations" dans les réglages OS. Naviguer sur tout le site. Les animations doivent se transformer (fade au lieu de slide) ou disparaître. Aucune animation multi-axes, aucun parallax, aucun zoom ne doit subsister.
  • Daltonisme : simuler avec Coblis (opens in a new tab) ou les DevTools Chrome (Rendering > Emulate vision deficiencies). Aucune information ne doit reposer sur la couleur seule. Chaque état doit être doublé par du texte ou une icône.
  • Contraste : vérifier chaque élément interactif avec WebAIM (opens in a new tab). Cible : 7:1 pour le texte courant (WCAG AAA).
  • Clavier : débrancher la souris. Naviguer sur tout le site au Tab. Chaque élément interactif doit être atteignable et activable (Enter/Space). Le focus doit être visible à tout moment.
  • Focus trap : ouvrir chaque modale/drawer/popover. Tab doit boucler à l'intérieur. Escape doit fermer. Le focus doit revenir à l'élément d'origine après fermeture.
  • Contenus dynamiques : chaque notification, toast, message de confirmation ou mise à jour de compteur doit utiliser aria-live. Tester avec VoiceOver (Mac) ou NVDA (Windows) : le changement doit être annoncé.
  • Sémantique : pas de <div> cliquables. Vérifier que chaque action utilise <button>, chaque lien utilise <a>, chaque champ utilise <input> ou <select> avec <label> associé.
  • Mobile : les animations lourdes (parallax, scroll-trigger, WebGL) doivent être allégées ou désactivées sur mobile et petit écran.

Le site que personne ne quitte

L'utilisateur vestibulaire revisite le site. Cette fois, "Réduire les animations" est activé sur son Mac. Le parallax a disparu. Les produits apparaissent en fade doux. Il parcourt tout le catalogue sans nausée.

L'utilisateur daltonien trouve le bouton "Ajouter au panier". Il est bleu avec le texte "Ajouter", et au clic, un check apparaît avec le mot "Ajouté". Même sans percevoir le changement de couleur, il sait exactement ce qui s'est passé.

L'utilisateur au clavier navigue avec un outline bleu qui suit chaque Tab. Il ouvre la fiche produit, le focus se piège dans la modale. Escape la ferme. Il retrouve sa position. Il commande.

Trois profils. Trois conversions. Un seul site. Et cette fois, c'est le vôtre.

L'accessibilité n'est pas un compromis sur l'expérience. C'est l'expérience portée à son plus haut niveau, celui où elle fonctionne pour tout le monde. Et un site qui fonctionne pour tout le monde, c'est un site que personne ne quitte.