Accessible Tooltips for WCAG 2.2: A Better Pattern for Modern Websites

Accessible Tooltips for WCAG 2.2: A Better Pattern for Modern Websites

If you are building websites or web apps for users in the EU, UK, or US, tooltip accessibility is no longer a niche frontend detail. It is part of basic product quality. The short answer is simple: an accessible tooltip should appear on hover and keyboard focus, stay available long enough to read, avoid obscuring important UI, and never be the only place where critical information lives.

That matters for SaaS dashboards, ecommerce flows, public-sector services, and any product where users rely on keyboard navigation, zoom, screen readers, large pointers, or forced-colors settings. A tooltip can still be useful, but only when it behaves like supportive text instead of a fragile hover trick.

Why are most tooltips inaccessible?

Most broken tooltips fail for the same reason: they were designed for a mouse, not for people.

The classic example is the title attribute. It still shows up in production code because it feels cheap and automatic, but it is a poor accessibility strategy. Native browser tooltips are inconsistent for keyboard users, unreliable on touch devices, and not something authors can shape into a dependable experience. They also tempt teams to hide useful guidance that should have been visible in the first place.

Another frequent mistake is using a tooltip as the entire label for an icon-only control. If a button only makes sense once the tooltip appears, the control is already under-described. The button still needs an accessible name of its own. The tooltip can add context, but it should not carry the full meaning of the action.

The third failure is timing. Many tooltips vanish the moment a pointer leaves the trigger, which becomes a real problem for people using magnification or large cursors. If users cannot move onto the tooltip without it disappearing, the content is effectively unreadable.

What should an accessible tooltip do?

WCAG 2.2 gives a strong practical baseline for content that appears on hover or focus. For a tooltip pattern, that translates into a few expectations.

First, it should open on both hover and keyboard focus. If only mouse users can discover the extra text, the pattern excludes keyboard users from the same context.

Second, it should remain visible while the pointer is over either the trigger or the tooltip itself. This is especially important for people who zoom the page, use enlarged pointers, or need more time to visually track the message.

Third, it should be dismissible. Escape is a sensible baseline when the tooltip obscures nearby content, and clicking elsewhere is a useful secondary behavior.

Fourth, it should stay short and non-interactive. A tooltip is not a tiny dialog. If you need links, buttons, form controls, or multiple paragraphs, you are usually building a disclosure, popover, or dialog instead.

Finally, it should survive high-contrast and forced-colors environments. It is common to see attractive tooltip styles collapse into unreadable blobs once system contrast settings take over. If you care about accessibility, you have to test that state.

What does a better tooltip pattern look like?

The example below uses plain HTML, CSS, and JavaScript. It does not depend on a framework or positioning library, which makes it easy to paste into CodePen, CodeSandbox, or a documentation page. The key accessibility choices are:

  • The trigger keeps its own accessible name.
  • The tooltip is connected with aria-describedby.
  • The tooltip opens on focus and hover.
  • The tooltip can be hovered without collapsing.
  • Escape and outside click dismiss it.
  • The styles include a forced-colors treatment.

HTML

<section class="tooltip-demo" aria-labelledby="tooltip-demo-title">
<h2 id="tooltip-demo-title">Quick actions</h2>
<p>Each control has its own label. The tooltip only adds optional context.</p>

<div class="action-row">
<button class="chip-button hint-trigger" type="button" aria-describedby="hint-export">
Export
</button>
<div class="hint-panel" id="hint-export" role="tooltip" hidden>
Downloads the current table as a CSV file.
</div>

<button class="icon-button hint-trigger" type="button" aria-describedby="hint-copy">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 9h11v11H9zM4 4h11v2H6v9H4z" fill="currentColor"></path>
</svg>
<span class="visually-hidden">Copy invite link</span>
</button>
<div class="hint-panel" id="hint-copy" role="tooltip" hidden>
Copies a share link without opening another screen.
</div>

<button class="chip-button hint-trigger" type="button" aria-describedby="hint-filter">
Filters
</button>
<div class="hint-panel" id="hint-filter" role="tooltip" hidden>
Reuses the saved filters for this report.
</div>
</div>
</section>

CSS

[hidden] {
display: none;
}

.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

.tooltip-demo {
--surface: #f7f1e8;
--card: #fffaf4;
--border: #d6c3ab;
--text: #2f2418;
--accent: #0f766e;
--accent-strong: #134e4a;
--hint-bg: #1f2937;
--hint-text: #f9fafb;

max-inline-size: 42rem;
margin-inline: auto;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 1.25rem;
background:
radial-gradient(circle at top right, rgb(15 118 110 / 0.12), transparent 30%),
var(--surface);
color: var(--text);
}

.action-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}

.chip-button,
.icon-button {
border: 1px solid transparent;
border-radius: 999px;
background: var(--card);
color: var(--text);
font: inherit;
cursor: pointer;
}

.chip-button {
padding: 0.75rem 1rem;
}

.icon-button {
display: inline-grid;
place-items: center;
inline-size: 3rem;
block-size: 3rem;
}

.icon-button svg {
inline-size: 1.25rem;
block-size: 1.25rem;
}

.chip-button:focus-visible,
.icon-button:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}

.chip-button:hover,
.icon-button:hover {
border-color: var(--border);
color: var(--accent-strong);
}

.hint-panel {
position: absolute;
z-index: 20;
max-inline-size: min(22rem, calc(100vw - 2rem));
padding: 0.75rem 0.9rem;
border: 1px solid transparent;
border-radius: 0.9rem;
background: var(--hint-bg);
color: var(--hint-text);
line-height: 1.45;
box-shadow: 0 14px 30px rgb(15 23 42 / 0.18);
}

.hint-panel::after {
content: "";
position: absolute;
inline-size: 0.95rem;
block-size: 0.65rem;
left: var(--arrow-offset, 1.5rem);
transform: translateX(-50%);
background: var(--hint-bg);
clip-path: polygon(0 0, 100% 0, 50% 100%);
}

.hint-panel[data-placement="top"]::after {
top: 100%;
}

.hint-panel[data-placement="bottom"]::after {
bottom: 100%;
rotate: 180deg;
}

@media (forced-colors: active) {
.chip-button,
.icon-button {
border-color: ButtonText;
background: ButtonFace;
color: ButtonText;
}

.hint-panel {
forced-color-adjust: none;
border-color: CanvasText;
background: CanvasText;
color: Canvas;
box-shadow: none;
}
}

JavaScript

const entries = [...document.querySelectorAll(".hint-trigger")].map((trigger) => {
const panelId = trigger.getAttribute("aria-describedby");
const panel = document.getElementById(panelId);

return {
trigger,
panel,
openTimer: 0,
closeTimer: 0,
};
});

let activeEntry = null;

const clamp = (value, min, max) => Math.min(Math.max(value, min), max);

const placePanel = (entry) => {
const { trigger, panel } = entry;

panel.hidden = false;

const triggerBox = trigger.getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const gap = 12;
const edgeGap = 12;
const viewportWidth = document.documentElement.clientWidth;

let top = window.scrollY + triggerBox.top - panelBox.height - gap;
let placement = "top";

if (top < window.scrollY + edgeGap) {
top = window.scrollY + triggerBox.bottom + gap;
placement = "bottom";
}

let left =
window.scrollX + triggerBox.left + triggerBox.width / 2 - panelBox.width / 2;

left = clamp(
left,
window.scrollX + edgeGap,
window.scrollX + viewportWidth - panelBox.width - edgeGap
);

const arrowOffset =
window.scrollX + triggerBox.left + triggerBox.width / 2 - left;

panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.setProperty("--arrow-offset", `${arrowOffset}px`);
panel.dataset.placement = placement;
};

const hideEntry = (entry, immediate = false) => {
window.clearTimeout(entry.openTimer);
window.clearTimeout(entry.closeTimer);

entry.closeTimer = window.setTimeout(() => {
entry.panel.hidden = true;
entry.trigger.removeAttribute("data-open");

if (activeEntry === entry) {
activeEntry = null;
}
}, immediate ? 0 : 90);
};

const showEntry = (entry, immediate = false) => {
window.clearTimeout(entry.openTimer);
window.clearTimeout(entry.closeTimer);

entry.openTimer = window.setTimeout(() => {
if (activeEntry && activeEntry !== entry) {
hideEntry(activeEntry, true);
}

entry.panel.hidden = false;
entry.trigger.dataset.open = "true";
placePanel(entry);
activeEntry = entry;
}, immediate ? 0 : 120);
};

entries.forEach((entry) => {
const { trigger, panel } = entry;

trigger.addEventListener("pointerenter", () => showEntry(entry));
trigger.addEventListener("pointerleave", () => hideEntry(entry));
panel.addEventListener("pointerenter", () => showEntry(entry, true));
panel.addEventListener("pointerleave", () => hideEntry(entry));

trigger.addEventListener("focus", () => showEntry(entry, true));
trigger.addEventListener("blur", () => hideEntry(entry, true));
});

document.addEventListener("click", (event) => {
if (!activeEntry) return;

const clickedInsidePanel = activeEntry.panel.contains(event.target);
const clickedTrigger = activeEntry.trigger.contains(event.target);

if (!clickedInsidePanel && !clickedTrigger) {
hideEntry(activeEntry, true);
}
});

document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && activeEntry) {
hideEntry(activeEntry, true);
}
});

window.addEventListener("resize", () => {
if (activeEntry) {
placePanel(activeEntry);
}
});

window.addEventListener(
"scroll",
() => {
if (activeEntry) {
placePanel(activeEntry);
}
},
true
);

This pattern keeps the semantics honest. The button is still the button. The tooltip is only supplementary text. That distinction matters because assistive technology users should understand the control even if the tooltip never appears.

Demo

https://medium.com/media/70729ad6aa0fbc6a1a52e70ff65e1283/href

When should you not use a tooltip?

If the information is essential for completing a task, do not hide it in a tooltip. Required field instructions, compliance warnings, pricing caveats, and validation rules should be visible in the interface. Tooltips are best for optional clarification, short definitions, or a compact explanation of an already-labeled control.

If you need richer content, especially on touch-heavy products, a small popover or inline help pattern is usually the better choice. Modern browser APIs are making those patterns easier to ship, but the design principle is older than the API surface: once the content becomes substantial, it deserves a more explicit container.

How can teams make tooltip accessibility repeatable?

One practical step is turning these checks into a reusable agent skill. Instead of re-explaining the rules on every ticket, a team can encode the pattern choice first: should this be a tooltip, inline help, a toggletip, a popover, or a dialog? That matters because many accessibility issues start before the code is written. Teams often solve the wrong problem well.

That kind of skill can also enforce the details that usually drift over time, such as keeping a real accessible name on the trigger, using aria-describedby for supplemental text, supporting both hover and focus, and testing forced-colors behavior. For design systems and product teams, that creates a more reliable review process than relying on memory alone.

It also helps with consistency across articles, design systems, and production code. Once the rules are captured in a reusable workflow, reviews become faster, junior developers get better defaults, and accessibility stops depending on whether one particular specialist happens to be in the room that day.

If you are opening this article together with the attached skill and agent files, think of them as a practical companion to the guidance above. The skill definition explains when an AI assistant should use this workflow, the agent metadata makes it easier to invoke consistently, and the supporting reference files separate the work into three useful layers: choosing the right pattern, building a baseline accessible implementation, and testing the final result.

That package is helpful because tooltip work rarely fails in just one place. Sometimes the markup is wrong. Sometimes the interaction is wrong. Sometimes the visual design breaks in forced colors. And sometimes the component should never have been a tooltip at all. By attaching the skill files to the article, readers can move from theory to a reusable review process that can support writing, design-system maintenance, code review, QA, and implementation work.

What is included in the attached agent package?

  • SKILL.md defines when the workflow should trigger and what rules it should enforce before code is changed.
  • agents/openai.yaml contains the agent-facing metadata so the workflow can be invoked consistently.
  • references/pattern-selection.md helps decide whether the UI should stay a tooltip or be upgraded to inline help, a toggletip, a popover, or a dialog.
  • references/baseline-pattern.md captures the minimum semantic and behavioral shape for an accessible tooltip.
  • references/testing-checklist.md turns the accessibility requirements into a repeatable review and QA checklist.

Final takeaway

Accessible tooltip design is less about visual polish and more about respecting how people actually navigate. Real users arrive with keyboards, touchscreens, screen readers, magnification, custom contrast settings, and different levels of motor precision. A tooltip that only works for a steady mouse cursor is not a modern tooltip.

The safest rule is straightforward. Keep the message short. Keep it supplemental. Make it available on hover and focus. Let people dismiss it. Test it in forced colors. And when the content starts doing more than explaining, switch to a better pattern.

💾 The entire project’s code is available on my GitHub.