Tooltip
Tooltip · role=tooltip, aria-describedby, Escape to dismiss
A supplementary label revealed on hover and focus, dismissible with Escape, never the only place critical information lives. The pattern where showing it on hover alone is the single most common way tooltips break for keyboard users.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
Tab to either trigger — the tooltip appears on focus, not only on hover. Escape hides it without moving your focus away.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift + Tab | moves focus to the trigger, which shows the tooltip — the primary way keyboard users see it |
| Escape | hides the tooltip; focus stays on the trigger, it is never moved |
Screen reader
- The trigger is announced as normal (its own role and name), followed by its description — the tooltip text — because the trigger's aria-describedby points at it.
- Nothing is announced as a separate interruption: a description is read as part of the trigger, not as a live announcement, so this pattern is silent for anyone who is not currently focused on the trigger.
- Moving focus away hides the tooltip and removes the description association until the trigger is focused again.
ARIA notes
- role="tooltip" on the bubble; the trigger carries aria-describedby pointing at its id — describedby, not labelledby, because a tooltip supplements the trigger's existing accessible name, it does not replace it.
- aria-describedby is added only while the tooltip is visible and removed when it hides, so a screen reader is never pointed at an id that is not currently meaningful.
- The trigger shows the tooltip on both focus and hover — hover alone is not sufficient, because it is entirely invisible to anyone who never moves a mouse.
- WCAG 1.4.13 (Content on Hover or Focus): the tooltip is dismissible (Escape, without moving focus), hoverable (the pointer can move from the trigger onto the tooltip itself without it disappearing — a short grace period on mouseleave allows that), and persistent (it does not vanish on its own timer while still hovered or focused).
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
import { cloneElement, useEffect, useId, useRef, useState, type ReactElement } from 'react'
// Accessible tooltip — the WAI-ARIA Tooltip pattern (role="tooltip" +
// aria-describedby), built around the trap that breaks most hand-rolled
// tooltips: showing it only on `mouseover`. A mouse-only trigger is invisible
// to anyone who navigates by keyboard, so this component shows on FOCUS as
// well as hover — focus is the primary trigger, hover is additive, never a
// replacement.
//
// `content` is a plain string, not a slot for arbitrary children — on purpose.
// A tooltip is a supplementary label, not a container: if what you want to
// show needs its own interactive elements (a link, a button), the ARIA
// tooltip role does not support that and the right pattern is a popover, not
// a tooltip. Enforcing `string` here makes that limitation impossible to
// violate by accident.
//
// Three requirements this component is built to satisfy, none of them
// optional (this is the whole teaching point of the pattern):
//
// 1. Focus AND hover both trigger it (not hover-only).
// 2. Escape hides the tooltip without moving focus off the trigger
// (WCAG 1.4.13, "dismissible" — the user stays exactly where they were).
// 3. Hoverable + persistent (WCAG 1.4.13): moving the pointer from the
// trigger onto the tooltip itself must not make it disappear, and it
// must not vanish on its own timer while still hovered or focused. A
// short grace period on mouseleave gives the pointer time to travel
// from the trigger onto the tooltip; the tooltip's own mouseenter
// cancels that timer.
export function Tooltip({ content, children }: { content: string; children: ReactElement }) {
const [open, setOpen] = useState(false)
const tooltipId = useId()
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearHideTimer = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current)
hideTimer.current = null
}
}
const show = () => {
clearHideTimer()
setOpen(true)
}
// Grace period, not an immediate hide — WCAG 1.4.13 "hoverable": the
// pointer needs time to travel from the trigger onto the tooltip bubble
// without the tooltip closing out from under it.
const scheduleHide = () => {
clearHideTimer()
hideTimer.current = setTimeout(() => setOpen(false), 150)
}
const hideNow = () => {
clearHideTimer()
setOpen(false)
}
useEffect(() => clearHideTimer, [])
useEffect(() => {
if (!open) return
function onKeyDown(e: KeyboardEvent) {
if (e.key !== 'Escape') return
// Dismiss the tooltip only — focus deliberately stays on the trigger.
// A tooltip that steals focus on Escape defeats its own purpose: the
// user was never doing anything else, they were just reading a label.
hideNow()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open])
const trigger = cloneElement(children, {
'aria-describedby': open ? tooltipId : undefined,
onFocus: (e: React.FocusEvent) => {
children.props.onFocus?.(e)
show()
},
onBlur: (e: React.FocusEvent) => {
children.props.onBlur?.(e)
hideNow()
},
onMouseEnter: (e: React.MouseEvent) => {
children.props.onMouseEnter?.(e)
show()
},
onMouseLeave: (e: React.MouseEvent) => {
children.props.onMouseLeave?.(e)
scheduleHide()
},
} as Record<string, unknown>)
return (
<span className="relative inline-block">
{trigger}
{open && (
<span
role="tooltip"
id={tooltipId}
// Hoverable: the bubble itself also cancels a pending hide, so the
// pointer can rest on the tooltip text (e.g. to select it) without
// it closing.
onMouseEnter={show}
onMouseLeave={scheduleHide}
className="absolute left-1/2 top-full z-50 mt-2 w-max max-w-[16rem] -translate-x-1/2 rounded-md border border-outline-variant bg-surface-container-low px-2.5 py-1.5 text-xs text-on-surface shadow-md"
>
{content}
</span>
)}
</span>
)
}
Accessibility pitfalls
AvoidThe tooltip only shows on mouseover/mouseout.
DoShow it on focus as well as hover — otherwise a keyboard user never sees it at all.
AvoidEscape (or any dismissal) moves focus off the trigger.
DoDismiss the tooltip only; leave focus exactly where it was.
Avoidaria-labelledby is used to attach the tooltip.
DoUse aria-describedby — a tooltip supplements the accessible name, it is not the name itself.
AvoidA tooltip contains a link or a button so it can be "more useful".
DoTooltips cannot safely contain interactive content — if you need that, the correct pattern is a popover (a dialog-like disclosure), not a tooltip.
AvoidMoving the pointer toward the tooltip to read more of it makes the tooltip disappear first.
DoGive the pointer a short grace period to reach the tooltip, and keep it open while the pointer is over the tooltip itself (WCAG 1.4.13 hoverable).