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.

Focus and hover both open the tooltip A focusable data point connected by aria-describedby to a tooltip, opened by either mouseover or focus and closed by Escape. data point tabindex 0, focusable role="tooltip" id="tip-3" · "Q3 revenue: 4.2M" aria-describedby focus OR mouseover → open Escape / blur → close
One focusable trigger opens the tooltip on either focus or hover, links it via aria-describedby, and closes it on Escape or blur.

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

  1. Make the trigger focusable. Add tabIndex={0} (and a role="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.
  2. Mirror hover with focus. For every onMouseEnter/onMouseLeave handler, add a matching onFocus/onBlur handler that toggles the same open state. This is the core of WCAG 2.1.1 keyboard parity.
  3. Give the tooltip a stable id and role. Render the tooltip with role="tooltip" and a deterministic id such as tip-${point.id}. Type the id as const tipId: string.
  4. 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.
  5. Add Escape to dismiss. In onKeyDown, when e.key === "Escape", set open to false and call e.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).
  6. Keep it open while pointed at. So users can move the pointer onto the tooltip to read or select text, debounce the close on mouseleave or make the tooltip itself a hover target before hiding.
  7. 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-trapping role="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-describedby should 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 tabindex on 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 blur before the next opens, or you will leave orphaned tooltips and duplicate aria-describedby targets 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.

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.