Les états que shadcn/ui vous laisse : chargement, succès, erreur, vide et mouvement réduit
shadcn/ui vous donne un bouton propre, pas un bouton de chargement. Les états d’interface difficiles que chaque projet shadcn bricole à la main, avec les timings qui les rendent justes.
Preet Suthar · · 5 min de lecture
- React
- shadcn/ui
- UX
- Accessibility
Cherchez « shadcn button loading state » et vous trouverez cent extraits qui font la même chose : ajouter une prop isLoading, remplacer le libellé par un spinner, désactiver le bouton. Livrez ça et votre application paraîtra pire qu’avant de l’ajouter.
Ce n’est pas une critique de shadcn/ui. Il vous donne un point de départ propre et vous laisse les états, volontairement. Mais « les états », c’est là que vit l’essentiel du ressenti d’une interface, et presque personne ne leur réserve du temps. Voici la liste des états que je gère dans chaque composant que je livre, et les timings précis qui séparent « fini » de « ça marche ».
1. Un chargement qui ne scintille pas
Le bouton de chargement naïf affiche un spinner à l’instant du clic. La plupart des requêtes se terminent en moins de 200 ms, donc ce que l’utilisateur voit est un flash de spinner puis le résultat. Cela se lit comme un tremblement.
Deux règles corrigent ça :
- Attendez avant d’afficher le spinner. Si la requête se termine en 150 ms environ, ne l’affichez jamais.
- Une fois affiché, gardez-le un temps minimum. Environ 400 ms. Un spinner qui apparaît 30 ms est pire que pas de spinner du tout.
C’est un hook, pas une prop. Voici la forme :
track prend la promesse. Le hook décide si la requête a été assez rapide pour sauter le spinner, maintient le spinner assez longtemps pour ne pas scintiller, et fait passer le bouton en succès ou en erreur quand la promesse se règle. Le hook useButtonFeedback de HextaUI et useDelayedLoading font exactement cela, et ils sont assez petits pour être lus d’une traite.
La même règle de délai s’applique aux skeletons. Un skeleton qui apparaît 50 ms et se fait remplacer par le contenu est un flash de gris. Retardez-le de 150 ms, et quand le contenu arrive, faites-le apparaître en fondu sans rien déplacer autour.
2. Succès et erreur, sur place
Après un enregistrement réussi, le bouton doit le dire. « Saved » avec une coche, pendant environ deux secondes, puis retour au repos. Après un échec, le bouton doit le dire aussi, et garder l’erreur plus longtemps, environ quatre secondes, parce que les gens ont besoin de temps pour lire une erreur et décider quoi faire.
Deux détails qui comptent plus qu’il n’y paraît :
- Ne réinitialisez pas tant que le pointeur est sur le bouton. Si quelqu’un survole, il lit. Réinitialisez après son départ.
- Ignorez les clics pendant que la requête est en cours. Pas avec
disabled, qui retire le bouton de l’ordre de tabulation et le fait sauter par les lecteurs d’écran. Utilisezaria-busyet ignorez le clic.
Un Switch a le même problème dans un espace plus réduit. Basculez-le, envoyez la requête, et si la requête échoue, rebasculez-le. L’utilisateur doit voir le retour en arrière, alors animez-le, et affichez un bref état d’attente pendant que l’enregistrement est en cours.
3. Invalide, sans punir la saisie
Secouez le champ quand une tentative de soumission le trouve invalide. Ne secouez jamais pendant que quelqu’un tape encore. La règle, c’est le déclencheur : la validation à la soumission ou au blur a droit à la secousse, la validation au changement non.
La secousse elle-même doit être courte, trois ou quatre oscillations en 300 ms environ, et sous prefers-reduced-motion elle devient un changement de couleur de la bordure, rien de plus.
Le :user-invalid natif aide ici. Il ne correspond qu’après une interaction de l’utilisateur, vous pouvez donc styler l’état invalide en CSS sans drapeau touched dans React.
4. Vide est un état, pas une absence
Une liste vide est l’endroit où les nouveaux utilisateurs atterrissent en premier, et le défaut est une zone blanche avec un en-tête au-dessus. Chaque état vide a besoin de trois choses : une icône ou une illustration pour que l’espace paraisse intentionnel, une phrase qui dit ce qui apparaîtra ici, et l’action qui fait apparaître quelque chose.
C’est peu coûteux à construire et presque toujours oublié, parce que la personne qui construit la fonctionnalité a des données de test. Videz votre base de données de temps en temps.
5. Mouvement réduit ne veut pas dire « aucun mouvement »
prefers-reduced-motion: reduce est actif pour une part significative des utilisateurs, et l’implémentation paresseuse est un * { transition: none } global. Cela retire l’information que le mouvement transportait. Un dialogue qui s’ouvre d’un coup sans fondu est plus difficile à lire qu’un dialogue qui apparaît en fondu.
La règle que j’applique : tout ce qui se déplace à l’écran devient un fondu. Tout ce qui est en fondu reste un fondu. Tout ce qui boucle s’arrête. Les durées raccourcissent, sans tomber à zéro.
Mettez la vérification à un seul endroit pour que chaque composant la lise :
Et partagez aussi les courbes d’easing et les durées. Si chaque composant choisit son propre cubic-bezier, l’application donnera l’impression d’avoir été construite par six personnes, parce que c’est le cas.
6. Un mouvement que vous pouvez interrompre
Ouvrez un accordéon, puis fermez-le au milieu de l’animation d’ouverture. Dans beaucoup d’implémentations, il finit de s’ouvrir puis se ferme, ou il saute. Le bon comportement est de repartir en arrière depuis là où il en est.
La Web Animations API rend cela facile : gardez une référence vers l’animation en cours et, au prochain basculement, lisez son temps courant et animez depuis la hauteur calculée actuelle. Les transitions CSS sur height: auto ne le permettent pas, et grid-template-rows: 0fr vers 1fr le permet mais est plus difficile à inverser à mi-chemin. Choisissez l’approche qui vous laisse interrompre.
7. Les cibles tactiles et le zoom iOS
Deux choses cassent sur téléphone que vous ne verrez jamais sur un portable :
- Les champs sous 16px zooment la page sur iOS Safari. Gardez le texte des champs à 16px sur les pointeurs grossiers même si votre thème est à 15px ailleurs. Une seule règle
@media (pointer: coarse)suffit. - Les cibles tactiles sous 44px ratent. Les boutons peuvent faire 32px de haut sur desktop et grandir sur les écrans tactiles via la même media query.
8. Droite à gauche
Si vous livrez un jour à des utilisateurs arabophones ou hébréophones, chaque ml-, pl- et left- dans vos composants est un bug. Utilisez les propriétés logiques : ms-, ps-, start-. shadcn/ui a ajouté la prise en charge du RTL en janvier 2026, donc les nouveaux composants que vous récupérez vont bien, mais pas ceux que vous avez copiés en 2024.
Où cela vous mène
Aucun de ces points n’est difficile. Tous ensemble, c’est quelques semaines de travail sur un ensemble de composants de la taille de shadcn/ui, et le travail est invisible une fois terminé, c’est pourquoi on le coupe.
J’ai construit HextaUI parce que je refaisais ce travail sur chaque projet. C’est un registre gratuit sous licence MIT que vous ajoutez à un projet shadcn/ui. Même CLI, mêmes noms de composants, mêmes tokens de thème, et chacun des états ci-dessus déjà géré, jusqu’au délai de spinner de 150 ms. Si vous préférez posséder le code et écrire les états vous-même, les hooks sont autonomes et vous pouvez n’installer que ceux-là.
FAQ
Dois-je désactiver un bouton pendant le chargement ? Définissez aria-busy="true" et ignorez les clics. disabled le retire de l’ordre de tabulation et le cache aux lecteurs d’écran en pleine action.
Combien de temps un état de succès doit-il s’afficher ? Environ deux secondes, plus longtemps si le pointeur est encore sur le contrôle. Les erreurs, environ quatre secondes.
Un skeleton est-il toujours meilleur qu’un spinner ? Un skeleton est meilleur quand vous connaissez la forme de ce qui arrive. Un spinner est meilleur pour les actions. Les deux ont besoin du délai de 150 ms.
Mouvement réduit signifie-t-il que je dois retirer toute animation ? Non. Remplacez les déplacements par des fondus, arrêtez les boucles et raccourcissez les durées. Gardez l’information.