Accessible CSS-Only Tooltips and Hover Cards With and
A tooltip looks like the simplest interaction on the page, yet it is one of the easiest to build in a way that excludes keyboard and screen-reader users. The narrow problem this guide solves: you want a small explanatory popover — a definition, a help hint, or a richer hover card — that appears on pointer hover and on keyboard focus, stays put long enough to read, and survives the cursor crossing the gap between trigger and bubble, all without a line of JavaScript. This page sits under Hover & Focus State Design, and it leans hard on the accessibility expectations described in WCAG Success Criterion 1.4.13, "Content on Hover or Focus."
What you get here:
- A trigger that reveals a popover on both
:hoverand:focus-within - A transparent bridge so the popover is hoverable across the gap
- A persistent popover that does not auto-dismiss on a timer
- An honest account of where CSS stops and a tiny script is needed
Why CSS-only, and where it falls short
The instinct to reach for the native title attribute is understandable and wrong. A title tooltip is not keyboard-focusable, cannot be hovered, disappears on its own timer, and is announced inconsistently across assistive technology. WCAG 1.4.13 sets three conditions for content that appears on hover or focus: it must be dismissible (you can remove it without moving the pointer or focus), hoverable (you can move the pointer onto the new content without it vanishing), and persistent (it stays visible until you move away, dismiss it, or it becomes invalid). The native title fails all three.
A CSS-driven popover gets you two of the three cleanly. By attaching the trigger to a real focusable element and revealing a sibling popover with :hover and :focus-within, you satisfy hoverable and persistent with no script. The dismissible criterion — pressing Escape to clear the popover without moving focus — is the one piece CSS cannot express, because there is no selector for "a key was pressed." For non-essential decorative hints this is often acceptable; for content that obscures other UI you should add the small handler shown in the variation section. Knowing exactly where the boundary lies is the point of choosing CSS deliberately rather than by accident.
Complete working implementation
The structure is a wrapper that owns the hover and focus state, a focusable trigger, and a popover that is its sibling so it can read the wrapper's state. The diagram below traces how the state on the wrapper feeds both the pointer and keyboard paths into the same visible popover.
<span class="tip">
<button type="button" class="tip__trigger" aria-describedby="tip-1">
What is a compositor?
</button>
<span role="tooltip" id="tip-1" class="tip__bubble">
The compositor is the browser thread that assembles already-painted
layers into the final frame. <a href="#layers">More on layers</a>.
</span>
</span>
.tip {
position: relative;
display: inline-block;
}
/* The trigger must be a real focusable control (button/link), not a div. */
.tip__trigger {
font: inherit;
border: 0;
background: none;
padding: 0;
color: #2d5bff;
text-decoration: underline dotted;
cursor: help;
}
.tip__bubble {
position: absolute;
left: 0;
bottom: 100%; /* sit above the trigger */
width: max-content;
max-width: 18rem;
padding: 0.6rem 0.8rem;
background: #1e2430;
color: #fff;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
/* Hidden but still in the accessibility tree as a labelled region. */
opacity: 0;
visibility: hidden;
transform: translateY(4px);
transition: opacity 0.15s ease, transform 0.15s ease,
visibility 0s linear 0.15s; /* delay hiding so the fade can run */
}
/* The transparent bridge: a pseudo-element fills the gap above the
trigger so the pointer never leaves a hoverable region (hoverable). */
.tip__bubble::after {
content: "";
position: absolute;
inset: 100% 0 auto 0; /* sits directly below the bubble, over the gap */
height: 8px;
}
/* Reveal on pointer hover AND when focus is anywhere inside the wrapper.
:focus-within keeps it open while focus moves into the bubble's link. */
.tip:hover .tip__bubble,
.tip:focus-within .tip__bubble {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition-delay: 0s; /* show immediately, only hiding is delayed */
}
.tip__bubble a {
color: #9cc0ff;
}
The popover starts with opacity: 0 and visibility: hidden. Keeping visibility rather than display: none means the element stays in the layout and in the accessibility tree as a labelled region, and the aria-describedby reference on the trigger stays valid so screen readers announce the description when the control receives focus.
The technique that makes it work
Two selectors do the heavy lifting. :focus-within matches the wrapper whenever focus lands on the trigger or on anything inside the popover, so a keyboard user can Tab from the trigger into a link inside the bubble and the bubble stays open — a plain :focus on the trigger would slam it shut the instant focus moved off the trigger. The second trick is the transparent bridge: the ::after pseudo-element extends the hoverable area across the visual gap between trigger and bubble, so moving the cursor up to click a link never crosses dead space that would drop the :hover state. Together they satisfy the hoverable and persistent halves of WCAG 1.4.13 with no script.
Variation: adding Escape-to-dismiss
The one criterion CSS cannot meet is dismissible. A tiny, progressively-enhanced handler closes the popover on Escape without moving the pointer or focus. It toggles an attribute the CSS already understands, so the base experience survives if the script never loads.
/* Honour an explicit dismissal flag set by the keyboard handler. */
.tip[data-dismissed="true"] .tip__bubble {
opacity: 0 !important;
visibility: hidden !important;
}
document.querySelectorAll(".tip").forEach((tip) => {
tip.addEventListener("keydown", (e) => {
if (e.key === "Escape") tip.dataset.dismissed = "true";
});
// Re-arm on the next hover/focus so it can show again.
tip.addEventListener("pointerenter", () => delete tip.dataset.dismissed);
tip.addEventListener("focusin", () => delete tip.dataset.dismissed);
});
For users who prefer less motion, drop the slide and keep only the fade so the popover does not travel across the screen — the same restraint covered for transitions in Smooth Hover Effects Without JavaScript.
@media (prefers-reduced-motion: reduce) {
.tip__bubble { transition: opacity 0.15s ease, visibility 0s linear 0.15s; transform: none; }
.tip:hover .tip__bubble,
.tip:focus-within .tip__bubble { transform: none; }
}
Browser support
:focus-within is supported in Chrome 60+, Edge 79+, Firefox 52+, and Safari 10.1+, so it is safe everywhere evergreen. :hover and transition carry no caveats. The only feature you cannot rely on in CSS is keyboard dismissal, which is why the Escape handler is plain DOM JavaScript with broad support rather than a CSS feature. No @supports guard is needed for the base popover.
FAQ
Can a pure CSS tooltip be dismissible per WCAG 1.4.13?
Partly. CSS alone cannot listen for the Escape key, so the dismissible criterion needs a small JavaScript handler for full conformance. The hoverable and persistent requirements are fully achievable in CSS using a padded bridge and :focus-within.
Should I use the title attribute for tooltips instead?
No. The native title attribute is not keyboard-focusable, is not hoverable, vanishes after a timeout, and is unreliable for screen readers. A CSS popover tied to a focusable trigger is far more accessible.
Why use :focus-within instead of :focus on the trigger?focus-within keeps the popover open while focus moves into the popover content, letting keyboard users reach links or buttons inside it. A plain :focus on the trigger would close the tooltip the moment focus left the trigger.
How do I stop the tooltip from disappearing when the cursor crosses the gap? Remove the visual gap from the hit area. Give the popover a transparent padding bridge or position it flush against the trigger so the hover region is continuous between the two elements.
Related
- Hover & Focus State Design — the parent guide covering interactive state patterns.
- Smooth Hover Effects Without JavaScript — compositor-friendly transitions for the reveal animation.
vs and Polyfill Alternatives — keyboard-aware focus styling for the trigger. - Reducing Motion Preferences in CSS — honouring reduced-motion in the reveal.
- Building Responsive Cards With Container Queries — pairing hover cards with container-aware layouts.
Related articles
More pages in the same section.