Tooltips hebben vertraging nodig, en daarna moeten ze die overslaan

Op de pagina's met vragen voor mijn product, FrontPrep, toon ik bedrijfslogo's naast de interviewvragen. Wanneer je met de muis over een logo beweegt (hover), verschijnt er een tooltip met de naam van het bedrijf.

Een bepaald aspect irriteerde me al enkele dagen. Als ik de cursor simpelweg over de pagina bewoog, verschenen er constant tooltips langs de route. De oorzaak was simpel: ik had de vertraging van de tooltip op 0 gezet, waardoor deze direct verscheen zodra de cursor over het logo bewoog.

Om dit op te lossen, voegde ik een transitievertraging van 200ms toe. Dit werkte, maar creëerde een nieuw probleem. In de gebruikersinterface zijn er rijen waar meerdere logo's naast elkaar staan, omdat dezelfde interviewvragen bij meerdere bedrijven worden gesteld. Nu betekende het overstappen van het ene naar het andere logo dat je telkens opnieuw 200ms moest wachten, wat traag aanvoelde en leidde tot een slechte gebruikerservaring.

Dit bericht gaat niet over het bouwen van een tooltip. Het gaat over één klein interactiepatroon, hetzelfde patroon dat je vindt in browser-toolbars en op diverse websites, maar waar veel mensen zich niet bewust van zijn.

Een oplossing

Ik verdeel mijn oplossing in drie delen:

  1. Je beweegt de muis over een logo. De tooltip wacht 200ms voordat hij opent.
  2. Een tooltip sluit, waarna een timer van 300ms start. Ik noem dit het warm window of de warm page. Als je over een ander logo beweegt terwijl deze timer van 300ms loopt (wanneer de pagina "warm" is), opent de tooltip direct, zonder wachttijd of animatie.
  3. Wanneer de timer van 300ms verloopt, keert alles terug naar normaal en wordt de pagina "koud". Als je vervolgens weer over een logo beweegt, moet de volgende tooltip opnieuw 200ms wachten. Zonder deze stap zou de eerste geopende tooltip de vertraging voor de hele pagina permanent uitschakelen.

Het proces in het kort: hover → wacht 200ms → tooltip opent (pagina is nu warm) leave → tooltip sluit → 300ms cooldown → hover over een andere tooltip vóór de cooldown → opent direct, pagina blijft warm → cooldown eindigt → pagina is koud, de wachttijd van 200ms is terug

In FrontPrep zijn mijn tooltips gebouwd met Radix en Motion. Ik demonstreer het patroon hieronder met een eenvoudigere React-versie.

Hoe de code werkt

Ik leg de code uit in de volgorde waarin de gebeurtenissen plaatsvinden wanneer je de cursor beweegt.

Stap 1: Je beweegt over een logo

Wanneer onMouseEnter wordt geactiveerd op een logo, wordt deze functie aangeroepen:

function handleEnter() {
  // `tooltips` is een useContext variabele waarin de isWarm status wordt bijgehouden tussen componenten
  if (tooltips.isWarm) {
    show();
    return;
  }
  // `openTimer` is een useRef variabele, dus .current is hoe je de waarde instelt/benadert.
  openTimer.current = setTimeout(show, tooltips.openDelay);
}

Bij het hoveren stelt dit component één vraag: is de pagina warm? Zo ja, dan opent de tooltip direct. Zo nee, dan start er een timer van 200ms. Het tooltips object komt voort uit de TooltipProvider.

Stap 2: De tooltip opent

Hier is de show functie die het openen van de tooltip afhandelt:

function show() {
  setInstant(tooltips.isWarm);
  setOpen(true);
  tooltips.markOpened();
}

Deze functie doet drie dingen:

  1. Hij kopieert de waarde van tooltips.isWarm naar een state genaamd instant.
  2. Hij opent de tooltip.
  3. Hij meldt aan de provider dat de tooltip open is, waardoor de pagina "warm" wordt.

De instant vlag wordt als data-attribuut aan de tooltip toegevoegd, wat CSS gebruikt om de beginanimatie over te slaan:

.tooltip[data-instant="true"] {
  transition-duration: 0ms;
}

Dit is de reden waarom een tooltip direct zonder animatie of vertraging opent wanneer de pagina warm is.

Stap 3: Je verlaat het logo

Wanneer onMouseLeave wordt geactiveerd op een logo, wordt deze functie aangeroepen:

function handleLeave() {
  clearTimeout(openTimer.current);
  if (!open) return;
  setOpen(false);
  setInstant(false);
  tooltips.markClosed();
}

Eerst moet worden vastgesteld of de tooltip momenteel open is. Stel dat je cursor in 50ms over een logo beweegt (veel minder dan 200ms); de timer gestart door handleEnter loopt dan nog en de tooltip is nog niet geopend. clearTimeout in handleLeave annuleert die timer, zodat de tooltip helemaal niet opent.

Als de tooltip wel open is (omdat de cursor langer dan 200ms bleef rusten of de pagina warm was), sluiten we de tooltip en informeren we de provider. De provider start vervolgens een cooldown-timer van 300ms. Als je vóór het einde van deze timer over het volgende logo beweegt, opent de tooltip direct.

Stap 4: De Provider

const warm = useRef(false);
const cooldownTimer = useRef(null);

useEffect(() => () => clearTimeout(cooldownTimer.current), []);

const tooltips = useMemo(
  () => ({
    openDelay,
    isWarm: () => skipWhenWarm && warm.current,
    markOpened() {
      warm.current = true;
      clearTimeout(cooldownTimer.current);
    },
    markClosed() {
      clearTimeout(cooldownTimer.current);
      cooldownTimer.current = setTimeout(() => {
        warm.current = false;
      }, warmFor);
    },
  }),
  [openDelay, warmFor, skipWhenWarm]
);

Dit is de gedeelde status die nodig is om dit patroon te laten werken. De pagina is ofwel warm of koud. markOpened annuleert lopende cooldowns; dit zorgt ervoor dat de pagina warm blijft wanneer je van het ene naar het andere logo beweegt. In feite annuleert elke nieuwe tooltip de cooldown die door de vorige is gestart.

Een belangrijk detail is dat isWarm een ref is en geen React-state, omdat het wijzigen ervan niet alle tooltips op de pagina opnieuw hoeft te renderen. Deze ref wordt alleen uitgelezen binnen de event handlers.

Waarom een timer van 200ms?

Als we een timer van minder dan 150ms kiezen, zal de tooltip waarschijnlijk openen wanneer een cursor er simpelweg overheen beweegt. Kiezen we meer dan 250ms, dan voelt de hover traag en defect aan. 200ms is in dit geval een goede, gebalanceerde waarde.

Oordeel en smaak

Als je AI vraagt om een tooltip te bouwen, zal het een volledig functionele tooltip maken. Maar uiteindelijk ben jij als mens degene die beslist of de gebouwde tooltip van voldoende kwaliteit is. Er zijn details die een "werkende" tooltip onderscheiden van een "gepolijste" tooltip, op dezelfde manier als dat een werkend product onderscheidt van een gepolijst product.

AI kan alleen een gepolijst product bouwen als je het daartoe stuurt. Dat kun je doen wanneer je smaak en oordeel hebt ontwikkeld, wat voortkomt uit jaren van ervaring, fouten en oefening. Je kunt wel gebruikmaken van de expertise van andere engineers of designers om hun standaard van een gepolijst product over te nemen, maar je bent uiteindelijk nog steeds degene die beoordeelt en beslist of het aan de standaarden voldoet om te worden gepubliceerd.

Skill: Tooltip Timing

Ik heb een Claude-skill gemaakt om het bovenstaande probleem op te lossen. Je kunt deze gebruiken om je eigen codebase te auditeren. Omdat elke codebase anders is en verschillende libraries gebruikt, is deze skill generiek geschreven.

Naam: tooltip Beschrijving: Tooltips hebben vertraging nodig zodat ze niet openen bij onbedoelde muisbewegingen.

Theorie

Een klik is altijd intentioneel, terwijl een hover dat niet is. De cursor beweegt over de pagina om ergens te komen en passeert onderweg elementen; een tooltip kan aan de hand van de hover alleen niet weten of de gebruiker hem echt wil openen. De 200ms is de manier om dit te bepalen.

.tooltip {
  transition-delay: 200ms;
}

De drie waarden en statussen

WaardeGetalWaarom
Open delay200msOnder de 150ms opent een cursor die alleen passeert de trigger al. Boven de 250ms voelt een bewuste hover traag aan.
Warm window300msLang genoeg om de beweging van de ene trigger naar de volgende te dekken. Kort genoeg zodat een hover een seconde later opnieuw moet wachten.
Close delay0msHet verlaten van een trigger moet duidelijk zijn. Er is dus geen reden om te wachten.

Gewenst gedrag

hover → wacht 200ms → tooltip opent (pagina is nu warm) leave → tooltip sluit → 300ms cooldown