Changer le thème

shadcn/ui et Radix : conserver l'accessibilité lors de la personnalisation

Easton editorial illustration: learning console with milestone tokens

La semaine dernière, un collègue m’a demandé : « Pourquoi ce bouton ne réagit pas au clavier ? »

J’ai hésité. On utilise pourtant shadcn/ui — comment est-ce possible ? En ouvrant les DevTools, j’ai vu qu’il avait enveloppé Tooltip.Trigger dans un <div> pour ajouter un style custom. Le problème venait de là.

Franchement, j’ai fait la même erreur. Au début avec shadcn/ui, je pensais qu’on pouvait « tout modifier » : le code est dans le projet, non ? Changer le style, remplacer une balise, ajouter un wrapper — ça semblait OK. Jusqu’au jour où la QA a signalé : navigation clavier morte, lecteur d’écran muet, flux d’interaction coupé.

Là j’ai compris : la « liberté » de shadcn/ui a un prix. Il vous donne le source, mais derrière il y a la magie accessibilité de Radix. Dès que vous touchez mal, la magie disparaît.

Cet article explique le lien entre shadcn/ui et Radix, surtout comment garder l’accessibilité en personnalisant. Vous saurez utiliser asChild, gérer le focus et comprendre l’héritage ARIA — au minimum, quoi modifier et quoi ne pas toucher.


shadcn/ui et Radix : quelle relation ?

Beaucoup de gens se trompent sur ce point : shadcn/ui n’est pas un paquet npm.

Pas de npm install @shadcn/ui. C’est une plateforme de distribution de code : vous copiez le source des composants dans votre projet, et le code vous appartient. Modifiez, supprimez — personne ne vous en empêche.

D’où vient l’accessibilité ? De Radix.

Radix UI est une bibliothèque de composants sans style, les Primitives. Pas de CSS, seulement le comportement : où va le focus à l’ouverture d’un Dialog, comment Dropdown Menu gère les flèches haut/bas, comment Tooltip se cache pour les lecteurs d’écran. Tout est conforme WAI-ARIA et testé avec NVDA, JAWS, VoiceOver.

shadcn/ui ajoute une couche Tailwind CSS par-dessus. Belle apparence, comportement Radix en dessous. Quand vous copiez le code d’un bouton, vous ne voyez que des classes Tailwind — mais la logique Radix est dedans.

En clair :

  • Radix = « ça fonctionne » : aria, role, focus, clavier
  • shadcn/ui = « c’est joli » : Tailwind, cohérence visuelle

Quand vous modifiez un composant shadcn/ui, vous touchez la surface ; le comportement vient de Radix. La surface, libre ; la couche du dessous, fragile.


asChild : atout ou piège ?

asChild est une propriété particulière de Radix. La plupart des « parties » d’un composant Radix la supportent.

Par exemple, Tooltip.Trigger rend par défaut un <button>. Pour attacher le Tooltip à un lien :

<Tooltip.Trigger asChild>
  <a href="/help">Centre d'aide</a>
</Tooltip.Trigger>

Avec asChild={true}, Radix ne rend plus son <button> : il clone votre enfant et lui transmet comportement et attributs. Le lien a tout le Trigger Tooltip : survol, focus clavier, bons attributs aria.

Pratique — mais piègegeux.

Si vous mettez un élément non focusable, l’accessibilité disparaît.

// ❌ Mauvais exemple
<Tooltip.Trigger asChild>
  <div className="my-custom-wrapper">Cliquez-moi</div>
</Tooltip.Trigger>

Un div n’est pas focusable au clavier (sauf tabIndex={0}), ne répond pas à Enter/Espace. Le lecteur d’écran ne le voit pas comme un bouton. Au clavier, l’utilisateur n’« atteint » jamais ce Tooltip.

La doc Radix le dit clairement : « If you were to switch it to a div, it would no longer be accessible. »

Souvent ce n’est pas un div direct, mais votre composant React :

<Tooltip.Trigger asChild>
  <MyButton>Cliquez-moi</MyButton>
</Tooltip.Trigger>

Ça marche, à deux conditions :

1. Votre composant doit spread props

Radix clone l’enfant en lui passant handlers, aria, ref. Sans spread, tout casse.

// ❌ Erreur : n'accepte pas props
const MyButton = () => <button className="btn">...</button>

// ✅ Correct : spread toutes les props
const MyButton = (props) => <button className="btn" {...props}>...</button>

2. Votre composant doit forward ref

Radix accède parfois au DOM (mesure, focus). Sans ref, erreur.

// ❌ Erreur : pas de ref
const MyButton = (props) => <button {...props}>...</button>

// ✅ Correct : forward ref
const MyButton = React.forwardRef((props, ref) => (
  <button {...props} ref={ref}>...</button>
))

Ces deux règles valent pour tout composant feuille, pas seulement Radix.

Usage avancé : empiler plusieurs composants Radix.

<Tooltip.Trigger asChild>
  <Dialog.Trigger asChild>
    <MyButton>Ouvrir la modale</MyButton>
  </Dialog.Trigger>
</Tooltip.Trigger>

Un bouton, Tooltip Trigger et Dialog Trigger à la fois — les deux comportements coexistent.


Focus et navigation clavier

La gestion du focus est la partie la plus négligée de l’accessibilité.

On pense au style, pas à ceux qui n’utilisent pas la souris. Utilisateurs clavier et lecteurs d’écran dépendent entièrement de la position du focus.

Radix automatise beaucoup. Exemple :

À l’ouverture d’un AlertDialog, le focus va sur le bouton Cancel.

Détail réfléchi : AlertDialog sert aux actions dangereuses (supprimer, quitter). L’action la plus probable est « annuler ». Focus sur Cancel → Enter ferme la modale, moins de risque d’erreur.

Si le focus restait sur Confirm ? Enter accidentel → suppression. Catastrophe.

Comportement aligné sur WAI-ARIA authoring practices. Vous n’écrivez rien.

Mais : si vous personnalisez le contenu, le focus peut partir au mauvais endroit.

Par exemple un champ dans la modale :

<AlertDialog.Content>
  <AlertDialog.Title>Confirmer la suppression ?</AlertDialog.Title>
  <AlertDialog.Description>Saisissez DELETE pour confirmer</AlertDialog.Description>
  <input placeholder="Saisir DELETE" />  {/* ajouté */}
  <AlertDialog.Cancel>Annuler</AlertDialog.Cancel>
  <AlertDialog.Action>Confirmer</AlertDialog.Action>
</AlertDialog.Content>

À l’ouverture, où va le focus ?

Radix cherche le premier élément focusable. Votre input est avant Cancel → focus dans l’input. Plusieurs Tab pour atteindre Cancel. Le flux prévu est cassé.

Solution : autoFocus ou réorganiser l’ordre.

<AlertDialog.Content>
  <AlertDialog.Title>Confirmer la suppression ?</AlertDialog.Title>
  <AlertDialog.Description>Saisissez DELETE pour confirmer</AlertDialog.Description>
  <AlertDialog.Cancel autoFocus>Annuler</AlertDialog.Cancel>  {/* focus forcé */}
  <input placeholder="Saisir DELETE" />
  <AlertDialog.Action>Confirmer</AlertDialog.Action>
</AlertDialog.Content>

Cancel en premier ou avec autoFocus — le focus ne part plus au mauvais endroit.

Même logique pour la navigation clavier.

Tabs : flèches gauche/droite pour changer d’onglet (WAI-ARIA). Un style custom qui écrase role="tab" → navigation clavier morte.

Dropdown Menu : flèches haut/bas, Enter, Esc — géré par Radix. Un onClick sur un item sans onSelect peut casser le clavier.

Test simple : posez la souris, parcourez tout au clavier.

  • Tab entre-t-il dans le composant ?
  • Les flèches changent-elles les options ?
  • Enter déclenche-t-il l’action ?
  • Esc ferme-t-il la modale ?

Si une étape bloque, l’accessibilité a un problème.


Héritage automatique des attributs ARIA

Radix vous épargne une grande partie du travail ARIA.

Il ajoute les bons role et aria-* :

  • Dialog : role="dialog" et aria-modal="true"
  • Tabs.Tab : role="tab" et aria-selected
  • Switch : role="switch" et aria-checked

Vous n’avez rien à faire — Radix s’en charge.

Une obligation reste : fournir un accessible name.

Les utilisateurs de lecteur d’écran doivent savoir ce que fait un bouton, comment s’appelle une modale, que saisir dans un champ. Sans nom, ils devinent.

Radix propose le primitive Label :

<Label.Root htmlFor="email-input">Adresse e-mail</Label.Root>
<Input id="email-input" />

Label.Root lie l’input ; le lecteur lit d’abord « Adresse e-mail », puis la valeur.

Pour un contrôle custom (pas un input natif), nommez-le manuellement :

<Switch aria-label="Activer le mode nuit" />
<Tabs.Tab aria-label="Détails du produit" />

Ou aria-labelledby vers un texte visible :

<div id="mode-label">Mode nuit</div>
<Switch aria-labelledby="mode-label" />

Validation : lancez un lecteur d’écran et parcourez le flux.

Mac : VoiceOver (Cmd+F5). Windows : NVDA (gratuit). Si vous entendez « bouton » au lieu de « bouton Valider la commande », il manque un accessible name.

Autre point : contraste des couleurs.

Radix ne gère pas le style — le contraste, c’est vous. WCAG exige au moins 4,5:1 (texte normal) ou 3:1 (grand texte). Les couleurs par défaut shadcn/ui passent souvent ; vos changements de palette, à vérifier.

Outil utile : WebAIM Contrast Checker.


Checklist pratique

Après chaque personnalisation shadcn/ui, vérifiez :

asChild

  • L’enfant est-il focusable ? (button/a/input, pas div)
  • Le composant custom spread-t-il toutes les props ?
  • Le composant custom forward-t-il ref ?

Focus

  • À l’ouverture, le focus va-t-il au bon endroit ?
  • À la fermeture, revient-il sur l’élément déclencheur ?
  • Avec des éléments focusables imbriqués, l’ordre de tabulation est-il logique ?

Clavier

  • Tab entre dans le composant ?
  • Flèches pour Tabs, Dropdown ?
  • Enter déclenche l’action ?
  • Esc ferme la modale ?
  • Espace pour Switch, Checkbox ?

ARIA

  • Chaque contrôle a un accessible name ?
  • Le lecteur d’écran annonce rôle et état correctement ?
  • Les changements dynamiques ont une zone aria-live adaptée ?

Visuel

  • Indicateur de focus visible ?
  • Contraste ≥ 4,5:1 ou 3:1 ?
  • L’information ne repose pas sur la couleur seule (icône ou texte) ?

Outils

  • Clavier : sans souris, tout le flux
  • Lecteur d’écran : VoiceOver (Mac) ou NVDA (Windows)
  • Automatisation : extension axe DevTools

Conclusion

shadcn/ui vous donne la liberté du code — avec des limites.

La limite, c’est le comportement accessible de Radix. Style, layout, classes : oui. Casser la logique du dessous : non. Remplacer button par div, oublier le spread props → les utilisateurs clavier paient le prix.

À retenir :

  • asChild : enfant focusable ; custom = spread props + forward ref
  • Focus : en personnalisant une modale, vérifiez où il va
  • ARIA : Radix ajoute role ; vous fournissez le label

Avant de livrer une modification, testez au clavier. Corrigez tout de suite — n’attendez pas la QA.

L’accessibilité n’est pas un « plus » : c’est une base. shadcn/ui et Radix font le plus dur ; à vous de ne pas gâcher leur travail.

FAQ

Quel est le lien entre shadcn/ui et Radix UI ?
shadcn/ui est une plateforme de distribution de code : vous copiez le source dans votre projet. L'accessibilité vient des Radix Primitives sous-jacents — attributs aria, gestion du focus, navigation clavier. shadcn/ui ne gère que le style Tailwind.
Comment utiliser asChild sans casser l'accessibilité ?
Trois points : l'enfant doit être focusable (button/a/input), pas un div ; le composant custom doit spread props, par ex. props => <button {...props} /> ; le composant custom doit forward ref.
Que surveiller pour la gestion du focus en personnalisant une modale ?
AlertDialog place le focus par défaut sur le bouton Cancel. Si vous ajoutez un champ de saisie ou autre élément focusable, le focus peut partir ailleurs. Utilisez autoFocus ou réorganisez l'ordre des éléments.
Comment tester l'accessibilité d'un composant ?
La méthode la plus simple : posez la souris et parcourez tout le flux au clavier. Tab entre-t-il dans le composant ? Les flèches changent-elles les options ? Enter déclenche-t-il l'action ? Esc ferme-t-il la modale ? Puis lancez un lecteur d'écran (VoiceOver sur Mac, NVDA sur Windows).
Radix ajoute déjà les attributs aria — que dois-je faire de plus ?
Fournir un accessible name à chaque contrôle. Les utilisateurs de lecteur d'écran doivent savoir à quoi sert un bouton ou comment s'appelle une modale. Utilisez aria-label ou aria-labelledby pour lier le texte visible.

8 min de lecture · Publié le: 30 mars 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog