with Tailwind CSS, understandable for everyone
A tooltip that only appears on mouse movement stays invisible to keyboard users and screen readers. Accessible tooltips with Tailwind CSS connect supplementary information via aria-describedby, respond to hover and focus alike, and close reliably with Escape.
Table of Contents
- 1. Why tooltips often stay invisible to screen readers
- 2. aria-describedby: connecting the tooltip text to the trigger
- 3. Hover and focus: why both triggers are needed
- 4. Escape to close and focus restoration
- 5. role tooltip versus the native title attribute
- 6. Popover with more content: aria-expanded and aria-haspopup
- 7. Positioning without a keyboard trap
- 8. Mobile and touch: tooltips without a hover concept
- 9. Tooltip patterns compared
- 10. Summary
- 11. FAQ
1. Why tooltips often stay invisible to screen readers
A self built tooltip made of a <div> that only appears on :hover via CSS practically does not exist for two groups of users: keyboard users, who focus the triggering element but never move a mouse over it, and screen reader users, for whom a purely visual show and hide without semantic association stays invisible. Both groups miss exactly the supplementary information a tooltip is supposed to provide, such as the explanation of a technical term or an icon button.
Tailwind CSS does not ship its own tooltip component, which leaves developers full control over the semantic implementation, but also transfers full responsibility for it. A tooltip pattern built purely with group-hover:block looks visually convincing, but without additional ARIA association and keyboard support remains unusable for a relevant part of the user base. This article shows how accessible tooltips and popovers emerge in practice with Tailwind CSS.
2. aria-describedby: connecting the tooltip text to the trigger
The foundation of every accessible tooltip is the programmatic association between the triggering element and the tooltip text via aria-describedby. Without this association, a screen reader only reads the element's own visible text when it is focused, the additional tooltip content stays unmentioned, even if it visually appears right next to it. aria-describedby points to the id of the tooltip element and ensures the text is automatically read after the main content.
It is important that the tooltip element itself remains present in the DOM, even when it is visually hidden via Tailwind utilities like opacity-0 and invisible. aria-describedby only works if the referenced element actually exists, a tooltip element completely removed from the DOM via x-if would leave the association pointing at nothing whenever the tooltip is not currently visible.
<!-- Tooltip element stays in the DOM (opacity/visibility toggled),
aria-describedby links it to the trigger regardless of visual state -->
<div class="relative inline-block group">
<button
type="button"
aria-describedby="tooltip-shipping"
class="text-slate-500 hover:text-slate-700"
>
<svg class="w-4 h-4" aria-hidden="true"><!-- info icon --></svg>
<span class="sr-only">Shipping cost information</span>
</button>
<div
id="tooltip-shipping"
role="tooltip"
class="absolute bottom-full mb-2 invisible opacity-0 group-hover:visible
group-hover:opacity-100 group-focus-within:visible
group-focus-within:opacity-100 transition-opacity
bg-slate-800 text-white text-xs rounded-lg px-3 py-2 w-48"
>
Free shipping on orders over 50 euros.
</div>
</div>
3. Hover and focus: why both triggers are needed
A tooltip that reacts exclusively to :hover remains completely unreachable for keyboard users, because they never touch the triggering element with a mouse. The Tailwind utility group-focus-within: solves this problem elegantly: as soon as the triggering element is focused with the Tab key, the tooltip receives the same visible presentation as on hover, without any additional JavaScript needed just for visibility control.
A second, often overlooked aspect: the tooltip must also stay visible while the mouse moves within the tooltip itself, in case it contains interactive content like a link. Without accounting for this, the tooltip disappears as soon as the mouse moves from the triggering element to the tooltip content, which WCAG success criterion 1.4.13 "Content on Hover or Focus" explicitly addresses: additional content on hover or focus must remain hoverable as long as the pointer stays over the content.
<!-- Tailwind's group-focus-within makes the tooltip appear on keyboard
focus, exactly like it does on mouse hover -->
<div class="relative inline-block group">
<button
type="button"
aria-describedby="tooltip-discount"
class="underline decoration-dotted text-slate-700"
>
Volume discount
</button>
<div
id="tooltip-discount"
role="tooltip"
class="absolute bottom-full mb-2 invisible opacity-0
group-hover:visible group-hover:opacity-100
group-focus-within:visible group-focus-within:opacity-100
transition-opacity bg-slate-800 text-white text-xs
rounded-lg px-3 py-2 w-56"
>
A 5 percent discount applies automatically from 10 units.
</div>
</div>
4. Escape to close and focus restoration
A pure CSS tooltip using group-hover and group-focus-within closes automatically as soon as focus leaves the triggering element, which is usually sufficient for simple tooltips. But as soon as a tooltip contains interactive content or is controlled as a more complex popover with JavaScript, the Escape key must additionally be supported for closing it, followed by returning focus to the triggering element. Without this support, an open, JavaScript controlled popover may remain permanently visible, even when the user actually wants to close it.
This requirement is not optional, it follows directly from WCAG 1.4.13: additional content that appears on hover or focus must be removable via a simple mechanism like Escape, without requiring the user to move the mouse pointer or focus across a specific distance. A JavaScript controlled popover should therefore always register a global keydown listener for Escape while it is open, and remove it again when closed.
// Alpine.js popover: Escape closes it and returns focus to the trigger
function popover() {
return {
open: false,
togglePopover() {
this.open = !this.open;
},
onKeydown(event) {
if (event.key === 'Escape' && this.open) {
this.open = false;
this.$refs.trigger.focus();
}
},
};
}
5. role tooltip versus the native title attribute
The native HTML title attribute produces a browser side tooltip that seems simple at first glance, but brings several serious accessibility problems along with it. The browser side tooltip appears only after a delay, cannot be styled with CSS, usually does not work at all on touch devices, and is read inconsistently or not at all by many screen readers. For these reasons, title is considered an unreliable foundation for accessible tooltips and should be replaced with a self built pattern using role="tooltip" and aria-describedby.
role="tooltip" explicitly signals to screen readers that this is a short, supplementary description, not interactive content. This role is intended exclusively for purely informational, non interactive tooltips. As soon as a tooltip contains links, buttons, or other interactive elements, the tooltip role is no longer appropriate, it then technically becomes a popover or dialog pattern with its own semantics.
6. Popover with more content: aria-expanded and aria-haspopup
As soon as a tooltip goes beyond a short text description and contains interactive elements like links or buttons, the tooltip technically becomes a popover. The triggering element then needs aria-haspopup="true" or a more specific value like aria-haspopup="menu", to announce that activating it opens an additional overlay. Additionally, aria-expanded signals whether the popover is currently open or closed, and must be dynamically updated on every open and close.
Unlike a pure tooltip, focus is actually allowed to move into a popover with interactive content, a user must be able to reach the contained link or button via Tab. This fundamentally distinguishes a popover from the plain role="tooltip" pattern, where a focus change into the tooltip content should never happen independently.
<!-- Popover with interactive content needs aria-haspopup and dynamic
aria-expanded, unlike a plain informational tooltip -->
<div class="relative inline-block" x-data="popover()" x-on:keydown="onKeydown">
<button
type="button"
x-ref="trigger"
aria-haspopup="true"
x-bind:aria-expanded="open"
x-on:click="togglePopover()"
class="text-sm font-medium text-sky-700 underline"
>
Show shipping options
</button>
<div
x-show="open"
x-transition
role="menu"
class="absolute z-10 mt-2 bg-white border border-slate-200 rounded-xl
shadow-lg p-4 w-64"
>
<a href="/shipping" role="menuitem" class="block text-sky-700 underline mb-2">
Shipping costs in detail
</a>
<a href="/pickup" role="menuitem" class="block text-sky-700 underline">
Store pickup
</a>
</div>
</div>
7. Positioning without a keyboard trap
When positioning a tooltip or popover near the edge of the screen, many implementations dynamically shift the overlay with JavaScript so it does not get cut off. This positioning logic must never interfere with the focus order, a popover that visually appears to the left of the trigger but sits after it in the DOM must not confuse the tab order. The DOM position should remain logical independent of the visual positioning, just as with any other form of CSS reordering.
A second trap arises when a purely informational tooltip accidentally contains focusable elements, for example an icon with its own tabindex="0" inside the tooltip text. A user tabbing through the page would then unexpectedly be pulled into the tooltip content, even though it was meant to be purely informational. Pure role="tooltip" content should therefore never contain its own focusable elements, that is a clear signal to use a popover pattern with aria-haspopup instead.
8. Mobile and touch: tooltips without a hover concept
Touch devices have no real hover concept, a finger either touches a screen or it does not, there is no intermediate state like with a mouse pointer. A tooltip that reacts exclusively to :hover therefore becomes effectively untriggerable on touch devices. The robust solution is to explicitly react to tap events on touch devices and turn the tooltip pattern into a toggle behavior, similar to a popover, instead of relying on CSS hover states.
A practical implementation detects touch capability via window.matchMedia('(hover: none)') and switches from pure CSS hover to a click or tap controlled Alpine.js toggle with a visible close button for these devices. This way, the tooltip content stays reachable even without a mouse and without a physical keyboard, without complicating the desktop experience with hover.
// Detect touch-only devices and switch tooltip behavior from hover to tap
function adaptiveTooltip() {
return {
open: false,
isTouchDevice: window.matchMedia('(hover: none)').matches,
handleTrigger() {
if (this.isTouchDevice) {
this.open = !this.open;
}
// on hover-capable devices, CSS group-hover already handles visibility
},
};
}
9. Tooltip patterns compared
The following overview shows common tooltip implementations and the respective recommended, accessible alternative.
| Area | Unsafe | Recommended pattern | Benefit |
|---|---|---|---|
| Tooltip foundation | Native title attribute | role="tooltip" + aria-describedby |
Styleable, reliably read aloud |
| Trigger event | hover only | hover + focus-within | Also reachable via keyboard |
| Closing | No Escape support | Escape with focus restoration | Complies with WCAG 1.4.13 |
| Interactive content | As a plain role tooltip | aria-haspopup popover | Focus can safely move in |
| Touch devices | CSS hover only | Tap toggle on hover: none | Triggerable without a mouse |
This comparison shows that accessible tooltips rarely fail because of a single class, but because of the consistent combination of ARIA association, a dual trigger event, and a reliable closing mechanism.
Mironsoft
Tailwind CSS, accessibility and WCAG-compliant frontend development
Tooltips that work for every user?
We audit existing tooltip and popover patterns for missing aria-describedby associations, missing Escape support, and touch compatibility, and implement accessible alternatives with Tailwind CSS and Alpine.js.
Tooltip Audit
Review of every tooltip and popover pattern for ARIA compliance
Keyboard and Touch Support
Hover, focus, and tap unified in one consistent pattern
Screen Reader Testing
Manual review of every tooltip with NVDA and VoiceOver
10. Summary
Accessible tooltips with Tailwind CSS rest on three pillars: a programmatic association via aria-describedby or role="tooltip", a dual trigger via hover and focus-within, and a reliable closing mechanism via Escape with focus restoration. The native title attribute reliably fulfills none of these requirements and should be replaced with a self built pattern.
As soon as a tooltip contains interactive content, it technically becomes a popover with its own aria-haspopup and aria-expanded semantics, into which focus, unlike a plain tooltip, is actively allowed to move. Touch devices without a hover concept need a tap controlled toggle behavior, so tooltip content stays reachable without a mouse and keyboard.
Accessible Tooltips with Tailwind CSS - the essentials at a glance
aria-describedby instead of title
Avoid the native title attribute, use role tooltip with aria-describedby instead.
hover AND focus-within
Both trigger events are needed, otherwise the tooltip stays unreachable for keyboard users.
Escape to close
WCAG 1.4.13 requires a simple mechanism to remove the additional content.
Popover instead of tooltip for interactivity
Interactive content needs aria-haspopup and aria-expanded, not role tooltip.