One tooltip for pointer hover and keyboard focus
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:
- Focus arrives while the pointer is elsewhere. The focus handler sets its reason and calls the renderer. The description opens unless dismissal is already active; no separate keyboard-only copy is created.
- The pointer leaves while focus stays. The trigger’s hover reason clears, but focus remains a reason to show the description. One input ending does not cancel the other input’s state.
- Escape is pressed on the focused trigger. Dismissal overrides the opening reasons and hides the description. The code makes no focus request, so dismissal and focus movement stay separate operations.
- Tab leaves and immediately returns. With both pointer zones inactive, blur clears dismissal before the delayed render. The new focus event can then reopen the description. The old timer cannot preserve the previous interaction’s dismissal flag.
- The pointer crosses onto the description. Trigger hover clears and description hover supplies the new opening reason. The delayed close gives that transfer time to reach the renderer; leaving both areas removes both hover reasons.
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
- Essential instructions hidden inside temporary descriptions.
- Modal dialogs, complex interactive panels or keyboard widgets.
- Application data submission or storage.
- Accessibility certification or compatibility with a former plugin.
Sources
-
W3C, Tooltip pattern, ARIA Authoring Practices Guide. Draft marked work in progress without task-force consensus; opened 24 October 2026. ↩
-
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.