Accessible Tooltips Triggered by Keyboard Focus
Chart tooltips that only appear on mouseover silently exclude every keyboard and screen-reader user, because focus and hover are not the same event. This guide makes tooltips appear on focus as well as hover, wires them to the trigger with aria-describedby, and adds Escape-to-dismiss so the content is reachable without a pointer.
This guide sits inside the keyboard navigation patterns section of the accessible interactive data visualization overview. It pairs naturally with adding keyboard navigation to canvas charts: once a data point can receive focus, this is the tooltip that should appear when it does.
Diagnostic checklist
Check these likely causes before reworking the tooltip:
How focus and hover both trigger the tooltip
The accessible pattern listens for both pointer and keyboard activation on a single focusable trigger, and associates the visible tooltip through an id/aria-describedby pair so assistive technology reads the description as part of the control.
Broken vs fixed
// ❌ BROKEN: hover-only, no association, no keyboard path
function PointTip({ point }: { point: DataPoint }) {
const [show, setShow] = useState(false);
return (
<g
onMouseOver={() => setShow(true)} // pointer only
onMouseOut={() => setShow(false)} // vanishes the instant you move away
>
<circle cx={point.x} cy={point.y} r={6} fill="#2563eb" />
{/* no tabindex → never focusable; no aria-describedby → screen reader sees nothing */}
{show && <text x={point.x} y={point.y - 12}>{point.value}</text>}
</g>
);
}
// ✅ FIXED: focus + hover, aria-describedby, Escape to dismiss
function PointTip({ point }: { point: DataPoint }) {
const [open, setOpen] = useState(false);
const tipId = `tip-${point.id}`; // stable id for aria-describedby
return (
<g>
<circle
cx={point.x}
cy={point.y}
r={6}
fill="#2563eb"
tabIndex={0} // A11Y: make the point focusable
role="button"
aria-describedby={open ? tipId : undefined} // A11Y: associate description on open
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)} // keyboard parity with hover
onBlur={() => setOpen(false)}
onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false); e.stopPropagation(); } }}
/>
{open && (
<g role="tooltip" id={tipId}>
<rect x={point.x + 8} y={point.y - 30} width={120} height={24} rx={4} fill="#0f172a" />
<text x={point.x + 14} y={point.y - 13} fill="#ffffff" fontSize={12}>
{point.label}: {point.value}
</text>
</g>
)}
</g>
);
}
The broken version ties everything to the pointer and never associates the tip with the control. The fixed version makes the point focusable, opens the tooltip on both focus and mouseenter, sets aria-describedby only while open so screen readers read the description, and dismisses on Escape.
The reason aria-describedby is toggled rather than left permanently in place is subtle but important. If the attribute pointed at the tooltip id at all times, a screen reader would try to resolve the reference even when the tooltip element is not rendered, producing either silence or a stale description. By setting the attribute only while open is true, the association is always backed by a real, present element. This also means a screen reader announces the trigger’s own name first (from its label or text) and only then appends the description, which is exactly the reading order users expect from a described control.
It is worth distinguishing the two activation paths the fixed component supports. Pointer users get the tooltip via mouseenter/mouseleave, which fire as the cursor passes over the hit area. Keyboard users get it via focus/blur, which fire as Tab or arrow-key navigation lands on and leaves the element. Both paths write the same open state, so there is a single source of truth for visibility and no chance of the two input modes disagreeing about whether the tooltip should be shown.
Step-by-step fix
- Make the trigger focusable. Add
tabIndex={0}(and arole="button"if it is a non-interactive shape) so keyboard users can reach the point. In a real chart this is usually a proxy element; see the companion canvas guide. - Mirror hover with focus. For every
onMouseEnter/onMouseLeavehandler, add a matchingonFocus/onBlurhandler that toggles the sameopenstate. This is the core of WCAG 2.1.1 keyboard parity. - Give the tooltip a stable id and role. Render the tooltip with
role="tooltip"and a deterministicidsuch astip-${point.id}. Type the id asconst tipId: string. - Wire aria-describedby. Set
aria-describedby={open ? tipId : undefined}on the trigger so the association exists only while the tooltip is rendered, avoiding dangling references. - Add Escape to dismiss. In
onKeyDown, whene.key === "Escape", setopento false and calle.stopPropagation()so the chart can still expose Escape for its own purposes if needed. This satisfies WCAG 1.4.13 (Content on Hover or Focus). - Keep it open while pointed at. So users can move the pointer onto the tooltip to read or select text, debounce the close on
mouseleaveor make the tooltip itself a hover target before hiding. - Never put interactive content in a tooltip. A
role="tooltip"must be a simple description. If you need links or buttons inside, switch to a focus-trappingrole="dialog"popover instead.
Verification
- Tab to the point. With the mouse untouched, Tab to a data point and confirm the tooltip appears and is announced. A screen reader should read the trigger’s name followed by the tooltip text.
- Escape works. With the tooltip open, press Escape and assert it closes:
console.assert(!document.getElementById(tipId), "tooltip not dismissed"). - Association is live only when open. Inspect the trigger in DevTools:
aria-describedbyshould appear when open and be absent when closed. - Hover still works. Confirm the pointer path is unchanged for mouse users.
- Pointer can reach the tooltip. Hover the point, then move onto the tooltip; it should not vanish mid-move.
Edge cases & gotchas
- SVG focusability is inconsistent. Older browsers ignore
tabindexon SVG child elements, and even where it works the focus ring rendering varies. The reliable pattern is to attach the tooltip behavior to an HTML proxy element overlaid on the chart rather than to a raw<circle>or<path>; this also matches the canvas proxy approach and keeps one code path for both renderers. - Tooltips that trap reading time. If your tooltip auto-hides on a timer, keyboard and screen-reader users may not finish reading it. Tooltips opened by focus must stay until blur or Escape, never on a timeout (WCAG 1.4.13, “persistent”).
- Multiple tooltips open at once. When arrow-key traversal moves focus quickly, ensure the previous tooltip closes on
blurbefore the next opens, or you will leave orphaned tooltips and duplicatearia-describedbytargets in the DOM.
Frequently Asked Questions
Why does mouseover not cover keyboard users?
mouseover and :hover fire only for pointing devices. Keyboard users move focus with Tab and arrow keys, which dispatch focus/blur events, not pointer events. Unless you also listen for focus, the tooltip never appears for them.
Should I use aria-describedby or aria-labelledby for the tooltip?
Use aria-describedby. The tooltip provides supplementary description (the value, a note) layered on top of the control’s own name. aria-labelledby would replace the accessible name, which is usually not what you want for a tooltip.
Is title attribute a valid tooltip?
No. The native title attribute is unreliable: it does not appear on keyboard focus in most browsers, its timing is uncontrollable, and it is poorly announced by screen readers. Build a real role="tooltip" element instead.
What if the tooltip needs a link or button inside it?
Then it is not a tooltip. A role="tooltip" must be a passive description. For interactive content, use a role="dialog" popover that manages focus, so keyboard users can move into and out of it deliberately.
Related
- Keyboard navigation patterns — the parent section for keyboard interaction.
- Adding keyboard navigation to canvas charts — make points focusable before attaching tooltips.
- SVG vs Canvas architecture — why SVG focusability is inconsistent and proxies are safer.