Sur une application métier que je développe, des cartes de réglages affichent chacune un petit badge « ? » qui ouvre une bulle d’aide au survol. Rien d’exotique, un pattern qu’on voit sur la moitié des interfaces web. Sauf que la bulle clignotait. On approchait la souris du badge, l’aide apparaissait, disparaissait, réapparaissait, dans un scintillement nerveux qui donnait l’impression que l’écran buggait. Et il buggait, un peu.
Ce genre de détail ne casse aucune fonctionnalité. On peut le laisser traîner pendant des mois, personne n’ouvre de ticket pour ça. Mais c’est exactement le genre de scintillement qui, chez l’utilisateur, installe un doute diffus : « ce logiciel n’est pas fini ». La perception de qualité d’un produit métier se joue autant sur ces micro-détails que sur les grosses features. C’est la vitre brisée appliquée au logiciel : un défaut visible qu’on laisse en l’état signale que personne ne regarde le reste de près, et il finit par en autoriser d’autres.
Pourquoi la bulle clignote
Le réflexe naïf pour ce genre de bulle : on ouvre au mouseenter du badge, on ferme au mouseleave. Ça marche tant que le curseur reste sur le badge. Le problème surgit dès qu’il y a un espace, même d’un pixel, entre le badge et la bulle, ce qui est presque toujours le cas puisque la bulle s’affiche à côté du badge, pas dessus.
Quand la souris quitte le badge pour se diriger vers la bulle, elle traverse ce vide. Le navigateur émet un mouseleave sur le badge → on ferme. La bulle disparaît sous le curseur → le badge se retrouve à nouveau sous la souris → mouseenter → on rouvre. mouseleave → on referme. À la fréquence de rafraîchissement de l’écran, ça donne un stroboscope. Le pointeur oscille sur une frontière instable, et chaque franchissement déclenche un événement contradictoire.
Le second problème apparaît quand on empile plusieurs cartes côte à côte : en balayant la grille avec la souris, on ouvrait trois bulles d’un coup, qui se chevauchaient. Deux bugs distincts, une seule cause de fond : on traite le survol comme un signal binaire instantané, alors que l’intention de l’utilisateur, elle, a une durée et un contexte.
Interpréter une intention plutôt que des événements
La bonne abstraction s’appelle hover intent. On ne réagit pas à chaque mouseenter/mouseleave isolé ; on interprète une intention : « l’utilisateur veut lire cette aide » (il s’attarde), « il a fini » (il s’éloigne pour de bon).
Un délai de grâce à la fermeture. Au mouseleave, on ne ferme pas tout de suite : on arme un timer de ~120 ms. Si le curseur arrive sur la bulle pendant ce délai, on annule le timer. Le petit vide entre badge et bulle devient franchissable.
La bulle fait partie de la zone sensible. Le mouseenter sur la bulle elle-même annule toute fermeture en cours ; son mouseleave réarme le timer. Badge et bulle forment une seule zone logique, même s’ils sont visuellement séparés.
Une seule bulle ouverte à la fois. Avant d’ouvrir, on ferme celle qui traîne. Un simple registre partagé au niveau du module suffit, pas besoin d’un state manager pour ça.
Voici le tout dans un contrôleur Stimulus, sans dépendance :
import { Controller } from "@hotwired/stimulus"
// Registre partagé : une seule bulle ouverte à la fois.
let openController = null
export default class extends Controller {
static targets = ["content"]
connect() {
// Sur écran tactile, pas de survol : on repassera au tap.
this.hoverable = window.matchMedia("(hover: hover)").matches
this.closeTimer = null
}
open() {
if (!this.hoverable) return
clearTimeout(this.closeTimer)
if (openController && openController !== this) openController.hide()
this.contentTarget.hidden = false
openController = this
}
scheduleClose() {
if (!this.hoverable) return
this.closeTimer = setTimeout(() => this.hide(), 120)
}
cancelClose() {
clearTimeout(this.closeTimer)
}
hide() {
this.contentTarget.hidden = true
if (openController === this) openController = null
}
}
Et le HTML, où le badge et la bulle sont tous deux câblés sur les mêmes actions :
<div data-controller="popover">
<button type="button"
data-action="mouseenter->popover#open mouseleave->popover#scheduleClose
focus->popover#open blur->popover#hide"
aria-describedby="help-<%= card.id %>">?</button>
<div id="help-<%= card.id %>" role="tooltip" hidden
data-popover-target="content"
data-action="mouseenter->popover#cancelClose mouseleave->popover#scheduleClose">
<%= card.help_text %>
</div>
</div>
Le cancelClose sur la bulle est la pièce maîtresse : c’est lui qui rend le vide entre badge et bulle franchissable. Retirez-le et le délai de grâce ne sert plus qu’à retarder le clignotement.
Deux détails qui font la différence
Le tactile n’a pas de survol. Sur téléphone ou tablette, mouseenter se déclenche au premier tap et ne repart jamais tant qu’on ne touche pas ailleurs, et la bulle reste collée. Le garde matchMedia("(hover: hover)") neutralise la logique de survol sur ces appareils ; là, un simple tap qui bascule l’affichage est plus honnête. Tester une interaction de survol uniquement à la souris est un piège classique : ça marche parfaitement sur le poste du développeur et casse sur le terrain, là où beaucoup d’utilisateurs métier sont sur tablette ou sur téléphone.
Le clavier et les lecteurs d’écran. J’ai câblé focus/blur en plus du survol, et lié le badge à sa bulle via aria-describedby. Une aide contextuelle qui n’existe qu’à la souris n’existe pas pour qui navigue au clavier. C’est peu de code, et ça évite d’exclure une partie des utilisateurs sans même s’en rendre compte.
Ce que je retiens
Le correctif tient en trois idées (délai de grâce, zone sensible étendue à la bulle, une seule ouverte) et une trentaine de lignes. Aucune librairie, aucun composant lourd. La difficulté était de voir le problème et de refuser de le laisser passer.
Un logiciel métier se juge à l’usage, dans les mille micro-frictions du quotidien plutôt que pendant la démo. Absorber ces frictions côté prestataire relève du travail invisible : ça ne remonte dans aucun cahier des charges, et l’utilisateur ne le remarque qu’en creux, le jour où l’outil cesse de se mettre en travers.
Si vous avez un outil interne qui « marche » mais que vos équipes trouvent vaguement pénible sans savoir dire pourquoi, c’est souvent une accumulation de ces détails-là. On peut regarder ensemble lesquels valent vraiment la peine d’être corrigés.