Short text hints linked via aria-describedby, visible on hover AND focus, dismissable with Escape, and hoverable so users can move onto them. Covers the titleanti-pattern and the tooltip-vs-popover distinction — Vanilla & React.
WCAG: 1.4.13 · 2.1.1 · 4.1.2
Level: Intermediate
Icons: lucide-react (HelpCircle, Info)
1
What we are building
A text hint attached to a trigger, keyboard-friendly, dismissable per WCAG 1.4.13.
What we are building
A tooltip is a short, text-only description attached to a trigger (usually a button or icon). It appears on both hover AND focus, is linked via aria-describedby so screen readers announce it after the trigger label, and must be dismissable, hoverable, and persistent per WCAG 1.4.13.
If your “tooltip” contains links or buttons, it’s not a tooltip — it’s a popover. Use role="dialog" instead. See Variant C for the comparison.
2
The 4 real-world variants
Switch between the inaccessible version and the accessible fix.
The 4 real-world variants
Variant A
Standard tooltip
Preferred
Live example
Hover or Tab to the ?. Esc dismisses. Pure text — no links or buttons inside.
HTML · Accessible
<!-- aria-describedby + role="tooltip"; shows on hover AND focus -->
<button
type="button"
aria-label="More info about promo codes"
aria-describedby="pc-tip"
>?</button>
<span role="tooltip" id="pc-tip" class="tooltip" hidden>
Enter a promo code to apply a discount at checkout.
</span>
<script>
// Show on mouseenter + focus, hide on mouseleave + blur
// Escape also dismisses (WCAG 1.4.13)
</script>
aria-describedby + role=tooltip. Appears on hover AND focus. Text only — no links or buttons inside.
Variant B
title attribute (anti-pattern)
Critical fix
Live example
title="..." fails on touch and has inconsistent SR support. Build a real tooltip.
HTML · Accessible
<!-- Real tooltip with aria-describedby + visible styled bubble -->
<span class="tip-wrap">
<button
type="button"
aria-describedby="exp-tip"
onmouseenter="show('exp-tip')"
onmouseleave="hide('exp-tip')"
onfocus="show('exp-tip')"
onblur="hide('exp-tip')"
>Export</button>
<span role="tooltip" id="exp-tip" hidden>
Export the current view as a CSV file
</span>
</span>
title='...' fails on touch, has inconsistent SR support, and can't be styled. Build a real tooltip instead.
Variant C
Tooltip vs popover
Pick correctly
Live example
Tooltip (role=tooltip)
Popover (role=dialog)
Tooltip = text only (hover/focus). Popover = actions inside (click to open, Esc to close).
HTML · Accessible
<!-- Interactive content → use role="dialog" (a popover) -->
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="account-menu"
aria-label="Account menu"
>Account</button>
<div
id="account-menu"
role="dialog"
aria-labelledby="menu-title"
hidden
>
<h4 id="menu-title">Account</h4>
<ul>
<li><a href="/profile">Profile</a></li>
<li><a href="/billing">Billing</a></li>
<li><button type="button">Sign out</button></li>
</ul>
</div>
<!-- JS: click to open, Esc to close, focus returns to trigger -->
If it has interactive content (links / buttons), it's a popover (role=dialog). Tooltips are text-only.
Variant D
Dismissable / Hoverable / Persistent
WCAG 1.4.13
Live example
Why do we ask for your phone?
Try moving the mouse from the ? onto the tooltip — it stays open (hoverable).Esc dismisses even while hovering.
JAVASCRIPT · Accessible
// WCAG 1.4.13: dismissable + hoverable + persistent
let closeTimer;
function scheduleClose() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => hide(), 120); // tiny grace period
}
function cancelClose() { clearTimeout(closeTimer); }
btn.addEventListener('mouseenter', () => { cancelClose(); show(); });
btn.addEventListener('mouseleave', scheduleClose);
btn.addEventListener('focus', () => { cancelClose(); show(); });
btn.addEventListener('blur', scheduleClose);
// Hoverable: moving the mouse onto the tooltip itself keeps it open
tip.addEventListener('mouseenter', cancelClose);
tip.addEventListener('mouseleave', scheduleClose);
// Dismissable: Escape closes without moving the pointer
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hide();
});
Grace period on close so users can move onto the tooltip; Escape dismisses even while hovering.
3
Flagship implementation
A reusable AccessibleTooltip with full WCAG 1.4.13 support.
Flagship implementation
AccessibleTooltip.jsx
import React, { useEffect, useId, useRef, useState } from "react";
import "./AccessibleTooltip.css";
/**
* AccessibleTooltip
* - label: accessible name of the trigger button (string)
* - content: text content of the tooltip (string — no interactive content!)
* - icon: optional lucide-react icon component for the trigger
*/
export default function AccessibleTooltip({ label, content, icon: Icon }) {
const id = useId();
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const timerRef = useRef(null);
function cancel() { clearTimeout(timerRef.current); }
function scheduleClose() {
cancel();
timerRef.current = setTimeout(() => setOpen(false), 120);
}
useEffect(() => {
if (!open) return;
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => () => cancel(), []);
return (
<span className="tip-wrap">
<button
ref={btnRef}
type="button"
aria-label={label}
aria-describedby={open ? id : undefined}
className="help-btn"
onMouseEnter={() => { cancel(); setOpen(true); }}
onMouseLeave={scheduleClose}
onFocus={() => { cancel(); setOpen(true); }}
onBlur={scheduleClose}
>
{Icon ? <Icon aria-hidden="true" size={14} /> : "?"}
</button>
{open && (
<span
role="tooltip"
id={id}
className="tooltip"
onMouseEnter={cancel}
onMouseLeave={scheduleClose}
>
{content}
</span>
)}
</span>
);
}
Usage · Form.jsx
import { HelpCircle } from "lucide-react";
import AccessibleTooltip from "./AccessibleTooltip";
export default function Form() {
return (
<label htmlFor="promo">
Promo code
<AccessibleTooltip
label="More info about promo codes"
content="Enter a promo code to apply a discount at checkout."
icon={HelpCircle}
/>
<input id="promo" type="text" />
</label>
);
}
4
Keyboard behavior
Keyboard users need the hint too — show on focus, dismiss with Escape.
Keyboard behavior
Action
Expected result
Tab to trigger
Tooltip appears (same as hover)
Blur
Tooltip disappears after a short grace period
Esc
Dismisses tooltip without moving pointer / focus
Mouse onto tooltip
Tooltip stays open (hoverable)
5
Screen reader expectations
Tooltip text is announced as the trigger's description.
Screen reader expectations
Trigger focused: “More info about promo codes, button, Enter a promo code to apply a discount at checkout”
Trigger with icon: “Help, button, Important hint”
Note: role="tooltip" alone doesn’t make SR announce the text — the aria-describedby link does the work.
6
WCAG mapping
Why each rule matters here.
WCAG mapping
Success Criterion
Why it matters
1.4.13 Content on Hover / Focus (AA)
Tooltip must be dismissable (Esc), hoverable, and persistent until dismissed or focus moves.
2.1.1 Keyboard (A)
Must appear on focus, not just hover.
4.1.2 Name, Role, Value (A)
Trigger exposes its label; tooltip linked via aria-describedby.
1.4.11 Non-text Contrast (AA)
Tooltip background vs. page must have 3:1 contrast at the edges.
2.5.5 Target Size (AA, 2.2)
Trigger button must be at least 24×24 px.
7
Testing approach
Framework-agnostic patterns. Pick the one that matches your stack.