CSS Hover Tooltip
Tooltips on hover built from CSS pseudo-elements and a data-tip attribute, with no JavaScript.
A CSS hover tooltip puts its text in a data-tip attribute and reads it back with content: attr(data-tip) on a pseudo-element. Two pseudo-elements do everything: ::before is the bubble, ::after is the arrow, and both sit at opacity: 0 until the parent is hovered. No script, no library, and no extra markup beyond the attribute.
It is also the pattern with the widest gap between how easy it is to build and how easy it is to get right. Text in a pseudo-element is not reliably announced, hover alone excludes keyboards and touch, and any ancestor with overflow: hidden clips the bubble away. The two versions below deal with the first two, and the last is covered in the notes.
Hover a word to see its tip
Hover over this word or this one to see tooltips.
HTML
<p>
Hover over
<span class="tip" data-tip="I'm a CSS tooltip!">this word</span>
or
<span class="tip" data-tip="No JavaScript needed">this one</span>
to see tooltips.
</p>
CSS
.tip {
position: relative;
display: inline-block;
cursor: default;
border-bottom: 2px dashed #b8ff57;
color: #b8ff57;
font-weight: 500;
}
/* the bubble */
.tip::before {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: .4rem .7rem;
border-radius: 6px;
background: #f0f0f0;
color: #0c0c0d;
font-size: .8rem;
font-weight: 400;
white-space: nowrap;
pointer-events: none;
opacity: 0;
z-index: 1;
transition: opacity .2s;
}
/* the arrow, drawn with a collapsed border box */
.tip::after {
content: "";
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #f0f0f0;
pointer-events: none;
opacity: 0;
transition: opacity .2s;
}
.tip:hover::before,
.tip:hover::after {
opacity: 1;
}
Other ways to build it
Reachable by keyboard, and actually announced
Two changes. tabindex="0" and :focus-visible put the trigger in the tab order and show the tip when it is reached, without also firing on every mouse click the way plain :focus would. And the text moves out of the pseudo-element into a real <span> that aria-describedby points at, so a screen reader reads it as a description of the button rather than not at all. Tab into the demo to see it.
HTML
<span class="tip-anchor">
<button class="tip-btn" aria-describedby="desc-1">Archive</button>
<!-- a sibling, not a child: inside the button it would become
part of the button's own accessible name -->
<span class="tip-bubble" role="tooltip" id="desc-1">
Moves the thread out of the inbox
</span>
</span>
CSS
.tip-anchor {
position: relative;
display: inline-block;
}
.tip-bubble {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 220px;
opacity: 0;
pointer-events: none;
transition: opacity .2s;
}
/* the arrow, still a collapsed border box */
.tip-bubble::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #f0f0f0;
}
/* focus-visible, not focus: a mouse click should not open it */
.tip-btn:hover + .tip-bubble,
.tip-btn:focus-visible + .tip-bubble {
opacity: 1;
}
A tip long enough to wrap
The default rule pins the bubble to one line with white-space: nowrap, which sends a long sentence straight off the side of the viewport. Removing it is not enough on its own, because a block level bubble would then stretch to whatever width it is given. width: max-content with a max-width is the pair that works: short tips hug their text, long ones wrap at the ceiling. The ceiling comes from a custom property, so one trigger can override it without a new class.
Set the retention window on archived threads or leave everything in place.
HTML
<span class="tip tip-wrap" data-tip="Keeps everything forever."
style="--tip-w: 180px">everything</span>
CSS
.tip-wrap::before {
/* max-content first, so a short tip still hugs its own text */
width: max-content;
max-width: var(--tip-w, 260px);
white-space: normal;
line-height: 1.45;
text-align: left;
}
How it works
The tooltip text lives in a data-tip attribute on the element. The ::before pseudo-element reads it with content: attr(data-tip) and paints it above the element. The ::after pseudo-element draws the arrow from a collapsed border box. Both are invisible by default and fade in on :hover through opacity, which is animatable where display is not.
attr() inside content is as old as generated content itself and works everywhere. It is worth being precise about what that does not cover: attr() used in any other property, to pull a number into a width for example, is a much newer feature that Can I Use records as unsupported in Firefox and Safari. Reading a string into content is safe. Reading a length into a layout property is not.
The bubble is positioned against the trigger, so the trigger needs position: relative. bottom: calc(100% + 8px) puts the bubble's lower edge eight pixels above the trigger's top edge, and left: 50% with transform: translateX(-50%) centers it without anyone knowing how wide it is. The same pair of declarations centers the event dot on a date cell in CSS calendar, and it is the standard way to center a box of unknown width.
The arrow is a border trick rather than a shape. Give an element no width, no height, and a six pixel border on all four sides, and the four borders meet as four triangles filling a twelve pixel square. Color one side and leave the rest transparent and a single triangle is left, pointing away from the colored edge. border-top-color therefore gives a downward pointing arrow under a bubble that sits above.
pointer-events: none on both pseudo-elements matters more than it looks. Without it the bubble is part of the trigger's hit area, so moving toward the bubble keeps it open in a way that feels sticky, and a bubble that overlaps a neighbouring link steals its clicks. The transition is on opacity because display cannot be animated in the ordinary way, and fading is what gives the tooltip a sense of arriving rather than blinking on.
The real limitation is containment. A pseudo-element is painted inside its parent's box, so any ancestor with overflow: hidden clips it, and a table cell or a scrolling panel will cut the bubble in half. Raising z-index does not help, because clipping happens before stacking. Escaping that needs the browser's top layer, which means the popover attribute or anchor positioning, and both are recent enough that a position: fixed fallback is still worth having.
CSS properties used
contentattr(data-tip)pulls the attribute's string into the pseudo-element. An emptycontentis what makes the arrow element exist at all.positionrelativeon the trigger andabsoluteon both pseudo-elements, so the bubble and arrow are placed against the word rather than the page.transformtranslateX(-50%)afterleft: 50%centers a box whose width nobody knows in advance.opacity- The only thing that actually animates. It is a compositor property, so the fade costs nothing, and it can be transitioned where
displaycannot. pointer-eventsnonekeeps the bubble out of the hit test, so it cannot hold itself open or steal clicks from whatever it covers.white-spacenowrapkeeps a short tip on one line. Removing it, together with amax-width, is what allows a longer tip to wrap.:focus-visible- Shows the tooltip for keyboard users without also firing it on every mouse click, which
:focusalone would do.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
generated content | 4 | 2 | 3.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
pointer-events | 4 | 3.6 | 4 | 12 |
calc | 19 | 4 | 6 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
anchor positioning | 125 | 147 | 26 | 125 |
Figures come from Can I Use for generated content on pseudo-elements, CSS Transitions, pointer-events, calc(), :focus-visible and CSS Anchor Positioning. The core tooltip needs nothing newer than 2012. Anchor positioning is listed because it is the way out of the clipping problem, and its figures show it is not yet a baseline you can rely on without a fallback. Can I Use also records attr() outside content as Chrome and Edge 133, unsupported in Firefox and Safari, so keep attr() to strings in content.
Accessibility notes
Text generated by CSS is not dependable content. Browsers vary in whether pseudo-element text reaches the accessibility tree, and a data-tip attribute means nothing to a screen reader on its own. When the tip carries information the reader needs, put it in a real element and point at it with aria-describedby, which is what the first variant below does. When it merely repeats a visible label, aria-hidden on the bubble and nothing else is the honest answer.
WCAG 1.4.13 sets three requirements for content shown on hover or focus. It has to be dismissible without moving the pointer, usually with the Escape key, which needs script. It has to stay visible while the pointer moves onto it, which pointer-events: none deliberately prevents, so a hoverable tooltip needs a different structure. And it has to persist until the reader moves away or dismisses it, rather than timing out. A pure CSS tooltip meets the third and struggles with the first two, which is a reason to keep it to short, non essential text.
Hover alone reaches neither keyboards nor touchscreens. Add :focus-visible alongside :hover and give the trigger a tabindex of zero if it is not already focusable, so it enters the tab order. On touch, first tap fires a synthetic hover and the next touch anywhere clears it, so a tooltip that carries anything important should be a disclosure the reader can open and close instead.
What you can build with it
- Abbreviation glossaries. Expanding an acronym on hover in running text, where the full form is a convenience rather than a requirement.
- Icon button labels. A name for an icon only control. The accessible name still has to be in
aria-label, since the tooltip is decoration. - Form field hints. Format examples next to an input, using the focusable variant so the hint arrives when the field is reached by keyboard.
- Data point details. Exact values on a chart or a heat grid, where the shape gives the pattern and the tip gives the number.
- Truncated text. The full string behind an ellipsis, which is the one case where the tooltip and the visible content are the same thing.
Mistakes worth avoiding
- An ancestor with
overflow: hidden. The bubble is clipped at that boundary and noz-indexwill rescue it, because clipping happens before stacking is considered. - Leaving
pointer-eventsoff the bubble. It joins the trigger's hit area, so it can hold itself open, and it intercepts clicks meant for whatever it covers. - Relying on the pseudo-element text being announced. Support varies, and the
data-tipattribute itself carries no meaning to assistive technology at all. - Using
:focusinstead of:focus-visible. The tooltip then appears on every mouse click of the trigger, which is noisy and looks like a bug. - Leaving
white-space: nowrapon a long tip. The bubble grows past the viewport edge and the end of the sentence is unreachable.
Frequently asked questions
How do I make a tooltip with CSS only?
data-tip attribute, set position: relative on the trigger, then use a ::before with content: attr(data-tip) positioned above it at opacity: 0. Fade it in on :hover. Add pointer-events: none so the bubble cannot intercept the pointer.Why is my CSS tooltip cut off?
overflow: hidden, auto or scroll, and a pseudo-element cannot escape its parent's clipping. Raising z-index will not help. Either remove the clipping on that ancestor, switch the bubble to position: fixed, or move to the popover attribute so the browser paints it in the top layer.Are CSS tooltips accessible?
aria-describedby when the text matters, and keep the CSS version for decoration.How do I show a tooltip on keyboard focus?
tabindex="0" if it is not already focusable, then add :focus-visible next to :hover in the selector. :focus-visible is the right one, because plain :focus also fires on mouse clicks and makes the tooltip appear when nobody asked for it.Can a CSS tooltip have multiple lines?
white-space: nowrap, give the bubble a max-width, and set width: max-content so short tips still hug their text instead of always filling the maximum. The second variant on this page does that, with the width coming from a custom property.