Tooltips : le bon délai d'affichage change tout
Un micro-détail qui fait la différence entre une UI soignée et une UI frustrante
Un tooltip qui s'affiche au quart de tour quand on promène la souris sur une page, c'est pénible. Un tooltip qui met 500ms à apparaître sur chaque élément qu'on survole, c'est tout aussi pénable. Le juste milieu existe, et il tient en deux chiffres : 200ms de délai, 300ms de cooldown.
Cet article décortique le pattern "delayed-then-instant" popularisé par Abhishek Jakhar sur Master.dev, et montre comment l'implémenter proprement, que vous soyez en React pur ou en HTML/CSS natif.
Le problème : des tooltips qui parasitent la navigation
Si vous avez déjà passé la souris sur une barre d'outils, vous avez vécu ça : un balayage horizontal déclenche une cascade de tooltips intempestifs. Chaque micro-passage ouvre une popup, puis la referme, créant une expérience saccadée.
Le réflexe naturel, c'est de mettre un délai. Mais un délai fixe de 200ms sur tous les tooltips résout un problème... et en crée un autre : quand on navigue volontairement d'un élément à l'autre, on attend 200ms à chaque fois. Ça semble court, mais multiplié par 3-4 éléments adjacents, l'utilisateur a l'impression que l'interface traîne.
Contexte visuel : imaginez une rangée de logos d'entreprises, chacun avec un tooltip au hover. Sans délai, chaque passage de souris ouvre un tooltip. Avec un délai fixe, passer d'un logo au suivant signifie attendre 200ms à chaque fois. Insupportable.
Le pattern warm/cold window
La solution est élégante : au lieu d'un délai binaire (ouvert ou fermé), on introduit un état intermédiaire.
Comment ça marche
- L'utilisateur survole un élément → un compteur de 200ms démarre.
- Si la souris reste au-delà de 200ms → le tooltip s'ouvre et la page devient "warm".
- La souris quitte l'élément → le tooltip se ferme, un cooldown de 300ms démarre.
- Si l'utilisateur survole un autre élément pendant le cooldown → tooltip instantané, sans attente.
- Une fois le cooldown expiré (300ms sans hover) → la page redevient "cold", le délai de 200ms est rétabli.
hover → 200ms → tooltip ouvert (page warm)
leave → fermeture tooltip → 300ms cooldown
├─ hover avant fin cooldown → ouverture instantanée, page reste warm
└─ cooldown fini → page froide, délai 200ms rétabli
Pourquoi 200ms ?
Ce n'est pas un chiffre sorti d'un chapeau. Les guidelines Nielsen Norman Group et les travaux sur l'UX des tooltips (voir StackExchange UX, 2010) convergent vers une fourchette de 300-500ms pour le délai standard. Mais dans le cadre d'une navigation active entre plusieurs éléments proches, 200ms est un bon compromis :
- En dessous de 150ms : le tooltip s'ouvre trop facilement, on revient au problème initial.
- Entre 150ms et 250ms : zone idéale pour filtrer les passages involontaires sans pénaliser l'intention.
- Au-dessus de 250ms : la latence devient perceptible et l'interface semble molle.
Le cooldown de 300ms, lui, correspond au temps que met un utilisateur pour passer d'un élément à l'autre dans une rangée dense (logos, icônes, boutons). Assez long pour couvrir le mouvement, assez court pour ne pas maintenir l'état warm en permanence.
Implémentation React
L'article original de Master.dev détaille une implémentation React propre. Le cœur du système repose sur un Provider partagé qui expose l'état "warm" via le Context API.
Le Provider (état partagé)
const warm = useRef(false);
const cooldownTimer = useRef(null);
const tooltips = useMemo(() => ({
openDelay, // 200ms
isWarm: () => warm.current,
markOpened() {
warm.current = true;
clearTimeout(cooldownTimer.current);
},
markClosed() {
clearTimeout(cooldownTimer.current);
cooldownTimer.current = setTimeout(() => {
warm.current = false;
}, 300); // warmFor = 300ms
},
}), [openDelay, warmFor]);
Points clés : isWarm est stocké dans une ref (pas un state React), car le modifier ne doit pas re-render tous les tooltips de la page. Elle n'est lue que dans les event handlers (onMouseEnter, onMouseLeave).
Le composant Tooltip
function handleEnter() {
if (tooltips.isWarm()) {
show(); // ouverture instantanée
return;
}
openTimer.current = setTimeout(show, tooltips.openDelay);
}
function handleLeave() {
clearTimeout(openTimer.current);
if (!open) return; // tooltip pas encore ouvert, on annule
setOpen(false);
tooltips.markClosed(); // démarre le cooldown 300ms
}
La touche CSS
Le composant copie l'état isWarm dans un data attribute data-instant, que le CSS utilise pour supprimer l'animation d'entrée :
.tooltip[data-instant="true"] {
transition-duration: 0ms;
}
Résultat : quand le contexte est warm, le tooltip apparaît immédiatement, sans fondu, sans délai. Net et précis.
L'alternative HTML/CSS native (sans JavaScript)
Chris Coyier a poussé le pattern plus loin en montrant qu'on peut obtenir exactement le même résultat avec uniquement du HTML et du CSS — pas une ligne de JS.
Le HTML
<button interestfor="tip-1" aria-labelledby="tip-1">
<svg>...</svg>
</button>
<span id="tip-1" popover="hint">
Je suis le tooltip
</span>
⚠️ L'attribut
popover="hint"est une nouveauté HTML qui lie l'ouverture au "interest" (hover, focus, etc.). Le navigateur gère tout seul l'affichage/ masquage.
Le CSS
button {
interest-delay-start: 200ms;
interest-delay-end: 150ms;
}
/* Quand un tooltip est ouvert dans la zone,
on supprime le délai pour les suivants */
.area:has(:popover-open) button {
interest-delay-start: 0s;
}
C'est tout. En deux règles CSS et un attribut HTML, on reproduit le pattern warm/cold sans useRef, sans setTimeout, sans Provider.
Fallback pour les navigateurs qui ne supportent pas
Safari ne supporte pas encore popover="hint". Chris propose un fallback astucieux utilisant une propriété CSS personnalisée --warm et une transition :
@supports not (interest-delay-start: 0s) {
.area {
--warm: 0;
transition: --warm 0s 700ms;
&:hover {
--warm: 1;
transition: --warm 0s 250ms;
}
}
[popover] {
transition-delay: calc((1 - var(--warm)) * 200ms);
}
}
Comparaison des approches
| Critère | React (Abhishek) | HTML/CSS natif (Chris) |
|---|---|---|
| Compatibilité | Universelle | popover="hint" : Chrome + Firefox (pas Safari) |
| Performance | Re-renders maîtrisés (refs) | Pas de JS, zéro surcoût |
| Maintenabilité | Dépend de la stack React | Fonctionne dans n'importe quel projet HTML |
| Contrôle fin | Animation, transition, customisation avancée | Limitée aux capacités CSS |
| Accessibilité | WCAG 1.4.13 manuel | popover="hint" + aria-labelledby automatique |
Mon conseil : si vous utilisez déjà une lib comme Radix UI ou MUI, l'approche React s'intègre naturellement dans votre code existant (avec un simple Provider à ajouter). Si vous partez d'une page statique ou d'un projet minimaliste, la version CSS native est plus élégante et plus rapide.
Accessibilité : WCAG 1.4.13
Ne pas oublier que les tooltips sont couverts par le critère WCAG 2.2 1.4.13 (Content on Hover or Focus). Trois règles impératives :
- Dismissible : l'utilisateur doit pouvoir fermer le tooltip sans déplacer la souris (touche Échap).
- Hoverable : la souris doit pouvoir survoler le tooltip sans qu'il disparaisse.
- Persistant : le tooltip reste visible tant que le hover/focus ou l'information est valide.
Dans l'implémentation React, ces trois points se gèrent avec :
- Un
onKeyDown={(e) => e.key === 'Escape' && close()}. - Un
onMouseEnterqui ne déclenche pashandleLeave(le tooltip reste). - Un pointeur de fermeture explicite si besoin.
L'approche native popover="hint" gère ces contraintes automatiquement, modulo l'ajout des attributs aria-labelledby sur le trigger et role="tooltip" sur le contenu.
Ce qu'il faut retenir
- 200ms de délai, 300ms de cooldown : un pattern simple, validé par l'expérience et les guidelines UX.
- Deux implémentations : React (Provider + useRef) ou CSS natif (popover hint + interest-delay-start).
- Accessibilité obligatoire : WCAG 1.4.13 s'applique à tous les tooltips personnalisés.
- Le diable est dans les détails : un délai fixe partout est pire que pas de délai du tout. Le warm/cold window est la solution éprouvée.
Et si vous utilisez une lib comme Radix ou Material UI : regardez leur API de "delay" ou "enterDelay". Beaucoup proposent déjà les hooks nécessaires. Parfois, il suffit de passer une prop enterDelay={200} et de wrapper dans un Provider custom.
La différence entre un produit qui fonctionne et un produit poli tient à des micro-détails comme celui-ci. Les tooltips, c'est un exemple parfait : tellement banal qu'on n'y pense pas, mais tellement important quand on les utilise tous les jours.



