How to create a CSS tooltip with HTML and CSS
The first step happens in HTML markup on the trigger. Everything after that is CSS.
- Put the tip text in a
data-tooltip attribute on the trigger. A button or link works best. - Give the trigger
position: relative so the bubble anchors to it. - Draw the bubble with
::after. Fill it using content: attr(data-tooltip) and set position: absolute. - Hide it with
opacity: 0 and visibility: hidden. - Reveal it through
:hover and :focus-visible on the trigger.
<button class="tip" data-tooltip="Copies the link to your clipboard">Copy link</button>
.tip { position: relative; }
.tip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: #222;
color: #fff;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
}
.tip:hover::after,
.tip:focus-visible::after {
opacity: 1;
visibility: visible;
}
Opacity alone leaves an invisible bubble sitting in the hit area, where it swallows clicks meant for the page underneath. Adding visibility: hidden removes it from hit testing until the reveal rule fires.
A span or div needs tabindex="0" before it can take keyboard focus. Buttons and links with an href already do.
Plenty of tutorials also add pointer-events: none to the bubble so it cannot flicker under the cursor, OpenReplay’s pure CSS walkthrough among them. It works, but it costs hoverability, and the WCAG section below weighs that trade.
Reading tooltip text with attr()
In January 2025, Chrome for Developers announced a redesigned attr(), available from Chrome 133, that works in any CSS property and parses typed values. With no type given, the value still parses as a plain CSS string, which is the behavior a tooltip relies on.
Pseudo-elements cannot read text from the DOM on their own. The attribute carries it, and attr() copies it into content.
The result stays text-only. Markup, links and images placed in the attribute print as literal characters.
Data attribute or child element?
The data attribute is the lighter option. One shared rule styles every tooltip, there’s no extra markup, and the text stays plain. A child element costs one extra element per trigger, but it holds longer text and can carry an id, which assistive technology needs.
For short labels I’d stay with the attribute. Move to a child element once the tooltip needs an id for aria-describedby, covered in the screen reader section below.
Positioning a CSS tooltip on any side, with an arrow
Each side comes down to a pair of offsets. One pushes the bubble past the trigger edge and the other centers it along the opposite axis. Swap the pair and the tooltip lands on another side.
Placement offsets by side
- For the top, use
bottom: 100%; left: 50%; transform: translateX(-50%). - The bottom takes
top: 100%; left: 50%; transform: translateX(-50%). - Left is
right: 100%; top: 50%; transform: translateY(-50%). - And right is
left: 100%; top: 50%; transform: translateY(-50%).
A data-position attribute or a modifier class on the trigger picks the side, with one override rule per value.
Spacing the bubble away with margin leaves dead space between trigger and bubble, and the pointer loses hover while crossing it. A transparent border on the bubble keeps that space part of the element instead.
Arrow with a pseudo-element
The bubble already uses ::after, so the arrow takes ::before. A zero-size box with one colored border and two transparent ones draws the triangle.
.tip::after {
bottom: 100%;
border-bottom: 6px solid transparent;
background-clip: padding-box;
}
.tip::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: solid transparent;
border-width: 6px 6px 0;
border-top-color: #222;
opacity: 0;
visibility: hidden;
}
.tip:hover::before,
.tip:focus-visible::before {
opacity: 1;
visibility: visible;
}
Move the colored border to another edge to point the arrow at another side. A triangle generator prints the border values for each direction.
Another route is clip-path: polygon(), which cuts a triangle out of a square pseudo-element. The arrow’s size then sits in ordinary width and height values instead of border widths.
Fade-in and delay values for a CSS tooltip
Transition opacity and visibility together, and put the delay on the show rule only. Opacity draws the fade, while visibility keeps the bubble on screen until the fade-out finishes.
.tip::after {
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s;
}
.tip:hover::after,
.tip:focus-visible::after {
opacity: 1;
visibility: visible;
transition-delay: 0.3s;
}
The durations above are placeholders to tune. The browser reads transition settings from the state being entered, so the delay in the hover rule applies on the way in, and the hide runs from the base rule with no delay.
The WAI-ARIA Authoring Practices tooltip pattern says a tooltip typically appears after a small delay. That delay also stops tooltips from flashing as the pointer sweeps across a row of controls.
Preview timing values in the browser before they go into the stylesheet.
One delay is worth avoiding entirely, the timed auto-hide. A tooltip that vanishes on a timer breaks the persistent condition of WCAG 1.4.13.
Reduced-motion handling
MDN lists prefers-reduced-motion as available in every major browser since January 2020, so the rule needs no fallback.
Drop any slide or scale built on transform. A plain opacity fade can stay, or you can switch the transition off.
Inside a media query for (prefers-reduced-motion: reduce), set transition: none on the bubble and the arrow.
Making a CSS tooltip work with keyboard and screen readers
Pair every :hover rule with :focus-visible, put role="tooltip" on a real element, and point to it from the trigger with aria-describedby. A hover-only tooltip never reaches keyboard users, because tabbing fires focus and never hover.
The web-features dataset from the WebDX Community Group lists :focus-visible as Baseline Widely available since 14 September 2024. Safari 15.4, shipped in March 2022, was the last major engine to add it.
Using it instead of plain :focus keeps the bubble from lingering after a mouse click on a button.
The markup is simple. A wrapper with position: relative holds the trigger and the tooltip as siblings. The tooltip is a real element with role="tooltip" and a unique id, and the trigger gets aria-describedby set to that id.
MDN’s reference for the ARIA tooltip role puts aria-describedby on the owning element, never on the tooltip. A rule built on the adjacent sibling combinator reveals the bubble on hover or focus.
The same page rules out aria-expanded here and says role="tooltip" goes on the element that holds the tip, never on an information icon that triggers it.
Text pulled in through attr() lives in a pseudo-element, which has no id for aria-describedby to reference. OpenReplay notes that CSS alone cannot communicate tooltip semantics to assistive technology, so an accessible tooltip needs the real element above.
Per the same authoring pattern, focus stays on the trigger and the tooltip never receives focus. A hover card with links is a different widget, a non-modal dialog.
The standards are less settled than the pattern suggests. The tooltip page carries a note that it lacks task force consensus, and an aria-practices repository item (#811) raises a conflict between the tooltip role and WCAG 1.4.13.
Does a CSS tooltip meet WCAG 1.4.13?
Partly. Hoverable and persistent come free with a well-built CSS tooltip. Dismissible only passes through an exemption, and real Escape dismissal needs a script.
W3C names custom tooltips as covered content in SC 1.4.13, a Level AA criterion introduced in WCAG 2.1 (June 2018) and carried into WCAG 2.2 (5 October 2023).
| Condition | What it requires | Pure CSS result | Where it fails |
|---|
| Dismissible | Hide the content without moving pointer or focus, unless it covers nothing else or reports an input error | Passes through the exemption when the bubble overlaps no other content | Bubble sits over text or controls with no Escape handler |
| Hoverable | Pointer can move onto the content without it vanishing | Passes when the bubble sits inside the trigger’s hover area | Gap between trigger and bubble, or pointer-events: none |
| Persistent | Stays until hover or focus leaves, the user dismisses it, or the information goes stale | Passes while :hover or :focus-visible holds | Timed auto-hide |
Read together, the criterion text and MDN’s tooltip guidance draw the line at Escape. MDN’s example hides the tooltip on the Escape key by toggling a class in JavaScript, and CSS has no selector for a key press.
So dismissal is the one condition CSS cannot own when the bubble covers other content. Put the bubble in clear space and the exemption applies. Over body text, a script has to handle Escape.
Pointer-events: none against the hoverable condition
OpenReplay’s pure CSS recipe adds pointer-events: none to the bubble to stop flicker. W3C’s Understanding page for 1.4.13 says users must be able to move the pointer from the trigger onto the content, because the pointer itself can cover part of it.
With the flag on, the bubble drops out of hit testing. The pointer lands on the page beneath it, hover on the trigger ends, and the tooltip closes.
The fix is to drop the flag and keep the bubble inside the trigger’s hover area. Hold any spacing as a transparent border on the bubble, the way the arrow technique in the positioning section does, instead of margin.
Where a CSS tooltip breaks: clipping, viewport edges and touch screens
An ancestor with overflow other than visible clips the bubble. The viewport edge cuts it off, since plain CSS cannot flip it. And touch screens never fire the hover that reveals it.
None of this shows up in a demo on a clean page. It surfaces in tables, card grids, sidebars and phones.
Clipped by overflow
An absolutely positioned bubble gets cut off by any ancestor with overflow: hidden, auto or scroll when the trigger sits inside that ancestor. Scrolling tables and card grids are the usual culprits.
- Move the tooltip element out of the clipping container, which then needs script or anchor positioning to find its trigger.
- If the ancestor only clips decoration, set it back to
overflow: visible. - Or render the bubble in the top layer with a popover (details in the popover section below).
Stacking behaves the same way. A z-index value only competes inside its own stacking context, so a bubble inside one card can still sit behind a sibling card, however high its number.
Cut off at the viewport edge
A bubble placed above its trigger runs off the top of the screen when the trigger sits at the top, and plain CSS has no rule that flips it.
Libraries solve this with collision logic that tracks the viewport and moves the bubble. Bootstrap’s tooltip defaults to cycling through top, right, bottom and left fallback placements against its clipping parents, per the Bootstrap 5.3 documentation.
CSS now has its own version of that logic, covered in the anchor positioning section below.
No hover on touch screens
MDN lists the hover media feature as Baseline Widely available, across browsers since December 2018, and its none value matches primary inputs that cannot hover or cannot do so conveniently, such as phones that emulate hover with a long tap.
Nielsen Norman Group’s tooltip guidelines (Alita Kendrick, January 2019) say tooltips are not normally available on touchscreens and name tap-triggered popup tips as the touch counterpart.
Put only the :hover reveal inside @media (hover: hover), because browsers that emulate hover on tap can leave the bubble open until the next tap elsewhere.
Keep the :focus-visible rule outside the query so a Bluetooth keyboard on a tablet still works.
Any text a touch user needs goes onto the page itself, never into the bubble.
How does CSS anchor positioning improve a CSS tooltip?
Anchor positioning links the bubble to its trigger in CSS. anchor-name goes on the trigger, position-anchor on the tooltip, and position-area picks the side. The browser keeps the bubble tethered through scrolling and resizing, and position-try-fallbacks flips it when it would leave the viewport.
.tip { anchor-name: --tip; }
.tip-bubble {
position: fixed;
position-anchor: --tip;
position-area: top;
position-try-fallbacks: flip-block;
}
MDN requires the bubble to be absolutely or fixed positioned. A fixed-position bubble also escapes ancestor overflow clipping, unless an ancestor creates a containing block for it (a transform does).
position-area works on a 3 by 3 grid with the trigger as the center tile, so top puts the bubble above it and bottom puts it below.
- Several triggers sharing one
anchor-name send every bubble to the last trigger in source order, and anchor-scope or unique names fix it (MDN). - Without
position-try-fallbacks the bubble overflows at the screen edge, while with it the browser tries the listed positions in order and returns to the default one if none fits. Hiding the bubble instead takes position-visibility. - A trigger set to
display: none drops the bubble back to its nearest positioned ancestor.
As of October 2026, support looks like this:
- Chrome and Edge from 125, and Safari from 26.
- Firefox from 147, which tipped the feature into Baseline Newly available (web.dev, 30 January 2026).
- Adoption sits at about 7.5% of page loads (Chrome Platform Status, via the web-features explorer; the counter moves daily).
Sources disagree on how settled this is. web.dev announced the core feature as Baseline Newly available in January 2026, while the web-features explorer’s October 2026 listing tags anchor positioning as Limited availability and shows Chrome and Firefox as unsupported.
The explorer’s underlying data shows where the gap sits. The newer position-visibility values anchor-valid and anchor-visible are listed only for Safari 27 (released 14 September 2026), and that keeps the whole entry in Limited availability. The core tethering web.dev announced is not affected. That is a cross-browser compatibility question to settle per property, so check MDN’s tables for each one you use and wrap the declarations in @supports (anchor-name: --x), with the classic offsets from the positioning section as the fallback.
Can the Popover API or interestfor replace a CSS tooltip?
Not yet for hover and focus. The Popover API gives a tooltip top-layer rendering, but interestfor, the declarative hover and focus trigger, ships only in Chromium as of the web-features explorer’s October 2026 release.
web.dev places the Popover API in Baseline Newly available since 27 January 2025, and the web-features data expects Widely available status on 27 July 2027. MDN notes that an open popover renders in the top layer, unaffected by parent position or overflow styling.
The popover attribute carries no semantics on its own, so the role="tooltip" and aria-describedby wiring from the accessibility section still applies.
<button interestfor="copy-tip" aria-describedby="copy-tip">Copy link</button>
<div id="copy-tip" popover="hint" role="tooltip">Copies the link to your clipboard</div>
A popover="hint" alone does not wire up triggers. Chrome for Developers calls its own hover-only hint demo inaccessible to anyone without a mouse, which is the gap interestfor was built to close.
The upsides are real. Top-layer rendering means no overflow clipping and no z-index fights, interestfor declares hover, focus and long-press triggers in HTML, and hint popovers leave open menus alone.
The costs are mostly about support.
interestfor runs in Chrome 142 (28 October 2025) and Edge 142 (31 October 2025), with no Firefox or Safari support.popover="hint" arrived in Chrome 133 (4 February 2025) and Firefox 149 (24 March 2026) as an older version of the spec. MDN’s compatibility data lists full support from Firefox 153 (21 July 2026) and Chrome 151 (28 July 2026), and Safari has it only in Technology Preview.- Hint popovers account for roughly 0.25% of page loads (Chrome Platform Status, via the web-features explorer).
Where Firefox and Safari matter, interestfor needs a polyfill. The main open-source one, mfreed7/interestfor on GitHub, calls its touch support limited: long-press works on buttons but not on links.
CSS tooltip vs title attribute vs JavaScript library
Short labels on focusable controls suit a CSS tooltip. A JavaScript library earns its place when edge collisions or rich content matter in browsers without anchor positioning. The title attribute is a supplement at best.
| Option | Keyboard and touch | Edge collisions | Cost |
|---|
| title attribute | No focus display, no touch | None | Zero code, no styling |
| CSS-only tooltip | Focus works, touch needs a fallback | None | No script |
| Anchor positioning with popover | interestfor in Chromium only | position-try-fallbacks | Support gaps |
| JavaScript library | Handled by the library | Flip and shift built in | Dependency plus init code |
TPGi’s updated guidance on the title attribute, quoting the HTML specification, says relying on it is discouraged because the tooltip needs a pointing device. That excludes keyboard-only and touch users.
The Bootstrap tooltip component needs Popper, either separately or through the bundle, and every tooltip must be initialized in JavaScript, per the Bootstrap 5.3 documentation. Floating UI, the successor to Popper, covers custom builds.
The same Bootstrap page adds a caution that the earlier tabindex="0" tip needs. Making non-interactive elements focusable that way creates confusing tab stops, and most assistive technologies do not announce tooltips there, so keep tooltips on links, buttons and form controls.
Nielsen Norman Group’s guidelines say tooltips must not carry information a user needs to finish a task, and Carbon’s design system keeps interactive elements out of them. The wider UI conventions for tooltips follow the same line.
That leaves a short set of rules.
- An icon-only control that needs a name gets a CSS-only tooltip.
- A bubble near a screen edge calls for anchor positioning with an
@supports fallback, or a library. - Links, buttons or forms inside the bubble mean it isn’t a tooltip anymore. Use a popover or dialog.
- Text a user needs to finish the task belongs on the page.
NN/g’s guidelines cite Amtrak’s account form for hiding password requirements in a hover tooltip, the textbook case of task information that belongs on screen. Alibaba’s unlabeled camera icon with a tooltip naming the image-search function is the opposite case, where the bubble names a control that has no label.
Skip the library for simple labels on focusable controls. Reach for one only when edge placement matters in browsers without anchor positioning.
CSS tooltip FAQ
How long should tooltip text be?
Short enough to read at a glance. The usual term is microcontent, a fragment that makes sense on its own. Nielsen Norman Group sets no character cap, but says lengthy text is no longer a tip and the bubble must not cover related content.
If the explanation needs a paragraph, put it on the page.
How do I make a multi-line tooltip wrap correctly?
Replace white-space: nowrap with white-space: normal, then give the bubble width: max-content and a max-width.
Without max-content, a bubble offset by left: 50% shrinks to the leftover width of its trigger and wraps after a word or two.
Why do two tooltips appear when the trigger also has a title attribute?
The browser draws its own tooltip from the title attribute while your bubble shows from data-tooltip. Remove title from the trigger and keep the text in the data attribute.
If the control has no visible text, add an aria-label so assistive technology still gets a name.
What is the difference between a tooltip and a toggletip?
A tooltip opens on hover or keyboard focus and closes once either leaves. Tap or click opens a toggletip instead, and it stays until the user closes it, which is why it suits touch screens.
Nielsen Norman Group calls the tap version a popup tip, and Carbon’s design system documents the split with a tooltip versus toggletip example.
Does Tailwind CSS need JavaScript for tooltips?
No. A Tailwind tooltip built with group-hover and group-focus utilities toggles a child bubble in pure CSS, the same mechanism as the plain version.
Tailwind ships utilities, not components, so edge collisions stay manual or come from a library.
Testing a CSS Tooltip Before It Ships
A CSS tooltip is ready to ship when Tab focus reveals it, the pointer can travel onto the bubble without closing it, and no touch user needs it to finish a task.
Do a keyboard pass first, with the mouse unplugged. Then a screen reader pass on the real trigger, and last a touch pass on a physical phone.
Keyboard goes first because it exposes a hover-only rule in seconds. Touch goes last because its fix is moving text onto the page.
The cost is that phone users see no bubble, so every label worth keeping needs visible text.
Verified on 27 October 2026, Firefox and Safari still lack interestfor. Once both ship it, a popover build could replace the CSS-only baseline.
The same three passes fit the other components in an interface, from dropdown menus to modals.
Linnea Koskinen
Design systems at Northvale
Shipping the new spacing scale on Friday. Tokens are in the release branch if you want an early look.