CSS Hover Tooltip

Tooltips on hover built from CSS pseudo-elements and a data-tip attribute, with no JavaScript.

Published May 19, 2026 Beginner 8 min read

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.

Moves the thread out of the inbox Hides it until tomorrow morning

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

content
attr(data-tip) pulls the attribute's string into the pseudo-element. An empty content is what makes the arrow element exist at all.
position
relative on the trigger and absolute on both pseudo-elements, so the bubble and arrow are placed against the word rather than the page.
transform
translateX(-50%) after left: 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 display cannot.
pointer-events
none keeps the bubble out of the hit test, so it cannot hold itself open or steal clicks from whatever it covers.
white-space
nowrap keeps a short tip on one line. Removing it, together with a max-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 :focus alone would do.

Browser support

FeatureChromeFirefoxSafariEdge
generated content423.112
transition455.112
pointer-events43.6412
calc194612
:focus-visible86415.486
anchor positioning12514726125

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 no z-index will rescue it, because clipping happens before stacking is considered.
  • Leaving pointer-events off 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-tip attribute itself carries no meaning to assistive technology at all.
  • Using :focus instead 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: nowrap on 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?
Put the text in a 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?
An ancestor has 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?
Not by default. Pseudo-element text is not reliably exposed, hover excludes keyboard and touch, and WCAG asks for content shown on hover to be dismissible and hoverable, neither of which CSS can do alone. Use a real element with aria-describedby when the text matters, and keep the CSS version for decoration.
How do I show a tooltip on keyboard focus?
Give the trigger 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?
Yes. Drop 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.