One tooltip for pointer hover and keyboard focus

How-to Last tested 24 October 2026

Give pointer hover and keyboard focus separate reasons to open the same short description, and let Escape override both until the interaction ends. Keep links, controls and essential instructions in a deliberate disclosure instead of making the tooltip carry the task.

Decide whether the reader needs to act

The example separates a description from a task. “File label” has a short tooltip. “Show file details” opens an inline panel containing a local link and a Close button. Neither control stores a file or submits a form; the second simply gives the reader somewhere to act.

Essential instructions belong in ordinary page content. Do not make someone discover a temporary description to understand the task. Here the tooltip repeats context, while longer content sits behind a clearly labelled disclosure control. The label remains useful when the description is closed.

The W3C tooltip pattern describes a hover-or-focus popup without focusable controls inside it. That pattern is marked work in progress without task-force consensus.1 Its scope is useful when choosing a presentation: a link, field or required decision needs another interaction, not just a larger tooltip.

Keep the opening reasons independent

The component tracks focus on the trigger, pointer hover on the trigger and pointer hover on the description. A fourth flag records dismissal. One render function combines those values; it does not treat the latest event as the whole interaction.

For example, leaving the trigger with the pointer should not erase keyboard focus. Moving onto the description should replace one hover reason with another. Escape needs a separate override so the description can close even while focus remains on its trigger.

The explanatory WCAG document for content on hover or focus separates dismissal, moving the pointer onto the extra content, and persistence.2 Keep those requirements separate in the state model. Opening the tooltip addresses only the beginning of that interaction; its lifetime also needs an ending the reader controls.

Make dismissal last for one interaction

Escape sets dismissed and renders a closed state without requesting a focus move. If the flag were cleared immediately, the remaining focus reason would reopen the description in the middle of the reader’s dismissal.

Clear dismissal when focus and both pointer reasons have ended. The blur handler does that synchronously when neither pointer zone is active, then schedules its normal delayed render. A quick return to the trigger can begin a fresh interaction without waiting for a previous close timer.

Pointer boundaries use a short delay to allow movement between the trigger and its description. That delay is an implementation setting, not an optimum for every layout. Keep focus state independent of it; a pointer-close timer should not decide whether a new keyboard interaction is allowed.

The complete example

Save this original, MIT-licensed HTML as a local file. It includes the tooltip, both pointer zones, focus and dismissal state, and the separate details panel. The SPDX comment identifies its licence. The description has one ID, shared by the trigger’s relationship and the rendering code.

<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One description for hover and focus</title>
<style>
  [hidden] { display: none !important; }
  :focus-visible { outline: 3px solid #a43b00; outline-offset: 2px; }
  .description { position: relative; display: inline-block; padding-bottom: .4rem; }
  #tip { position: absolute; top: 100%; left: 0; width: 18rem; padding: .6rem; background: #173b56; color: white; }
</style>
<div class="description">
  <button id="tip-trigger" aria-describedby="tip">File label</button>
  <div id="tip" role="tooltip" hidden>A short project label, not the file contents.</div>
</div>
<button id="next">Next control</button>
<button id="details-trigger" aria-expanded="false" aria-controls="details">Show file details</button>
<div id="details" hidden>
  <p>Use a label your team can recognize. This example stores and sends nothing.</p>
  <a href="#note">Read the local note</a>
  <button id="details-close">Close details</button>
</div>
<p id="note">Local note: demonstration text.</p>
<script>
  const trigger = document.querySelector('#tip-trigger');
  const tip = document.querySelector('#tip');
  let overTrigger = false, overTip = false, focused = false, dismissed = false, delay;
  function render() {
    clearTimeout(delay);
    const active = overTrigger || overTip || focused;
    if (!active) dismissed = false;
    tip.hidden = !active || dismissed;
  }
  function schedule() {
    clearTimeout(delay);
    delay = setTimeout(render, 160);
  }
  trigger.addEventListener('pointerenter', event => {
    if (event.pointerType === 'touch') return;
    overTrigger = true;
    render();
  });
  trigger.addEventListener('pointerleave', event => {
    if (event.pointerType === 'touch') return;
    overTrigger = false;
    schedule();
  });
  tip.addEventListener('pointerenter', () => {
    overTip = true;
    render();
  });
  tip.addEventListener('pointerleave', () => {
    overTip = false;
    schedule();
  });
  trigger.addEventListener('focus', () => {
    focused = true;
    render();
  });
  trigger.addEventListener('blur', () => {
    focused = false;
    if (!overTrigger && !overTip) dismissed = false;
    schedule();
  });
  document.addEventListener('keydown', event => {
    if (event.key === 'Escape' && !tip.hidden) {
      dismissed = true;
      overTip = false;
      render();
    }
  });
  const detailsTrigger = document.querySelector('#details-trigger');
  const panel = document.querySelector('#details');
  function closeDetails() {
    panel.hidden = true;
    detailsTrigger.setAttribute('aria-expanded', 'false');
    detailsTrigger.focus();
  }
  detailsTrigger.addEventListener('click', () => {
    panel.hidden = !panel.hidden;
    detailsTrigger.setAttribute('aria-expanded', String(!panel.hidden));
  });
  document.querySelector('#details-close').addEventListener('click', closeDetails);
  panel.addEventListener('keydown', event => {
    if (event.key === 'Escape') {
      event.stopPropagation();
      closeDetails();
    }
  });
</script>
</html>

Walk through the state changes

The demo retains the instrumented version. Follow these paths in the trimmed component above:

Keep focus on a known control while following the pointer cases. Otherwise, a description held open by focus can conceal a broken hover transfer. Allow the close delay to finish before treating a visible description as persistent.

Give longer content a deliberate route

The details button toggles an inline disclosure, not a modal dialog. It changes aria-expanded and reveals a panel with its own Close button. Closing requests focus on the details trigger. The panel’s link belongs to that deliberate interaction rather than the descriptive tooltip.

The tooltip’s trigger-hover handler ignores events whose pointerType is touch. The details button provides a separate activation route, and the example intercepts neither scrolling nor pinch gestures. Keep essential information available through visible text or an explicit action instead of making hover its only route.

Choose the panel’s contents around the task. A short explanation can sit beside a control; several fields or decisions need more space and a clear exit. Calling a component a “popover” does not by itself specify whether it is modal, how it opens or where closing returns the reader.

Replacing an existing integration

List the application’s description and disclosure tasks before replacing a jQuery tooltip or image-preview plugin. Preserve the behavior the application requires, including closing and return targets. This route uses new code and wording, without reproducing a former plugin or its options.

Not covered on this page

Sources

  1. W3C, Tooltip pattern, ARIA Authoring Practices Guide. Draft marked work in progress without task-force consensus; opened 24 October 2026. ↩

  2. W3C, Understanding Success Criterion 1.4.13: Content on Hover or Focus, WCAG 2.2. Informative explanation, opened 24 October 2026. ↩

Code on this page is original and MIT licensed.

How we test: methodology. Found a mistake? Corrections.