Why CSS Tooltips Still Matter
Tooltips are one of the most frequently needed UI patterns on the web. Whether you need to explain an icon button, clarify a form field, or surface supplementary information without cluttering the layout, a well-crafted tooltip delivers exactly the right information at the right moment. While JavaScript libraries can build tooltips, pure CSS implementations are lighter, faster, and often more than sufficient for most use cases.
In this guide, we'll cover three progressively advanced approaches: a classic CSS-only tooltip using ::before/::after pseudo-elements with position: absolute, a more robust data-tooltip attribute approach, and finally the modern Popover API that HTML now ships natively. We'll also address accessibility throughout — including aria-describedby — because a tooltip that screen reader users can't access is a broken tooltip.
Method 1: Classic CSS Tooltip with Pseudo-Elements
The simplest CSS tooltip uses the ::before tooltip pseudo-element technique. You position a tooltip bubble absolutely relative to the trigger element, then toggle its visibility on :hover and :focus.
The HTML Structure
<button class="tooltip-trigger" aria-describedby="tip1">
Save File
<span class="tooltip" role="tooltip" id="tip1">Ctrl + S</span>
</button>
The CSS
.tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: #f8fafc;
font-size: 0.75rem;
white-space: nowrap;
padding: 5px 10px;
border-radius: 6px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateX(-50%) translateY(4px);
}
/* Arrow */
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #1e293b;
}
/* Show on hover AND focus for keyboard users */
.tooltip-trigger:hover .tooltip,
.tooltip-trigger:focus-within .tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
Notice two critical details: we use :focus-within so keyboard users who tab to the button also see the tooltip, and we link the tooltip's id to the trigger's aria-describedby so screen readers announce it. The role="tooltip" attribute makes the semantics explicit.
Method 2: The data-tooltip Attribute Pattern
If you have many elements needing tooltips, the data-tooltip attribute pattern is cleaner — you write a single CSS rule set and then simply add data-tooltip="..." to any element.
<!-- HTML: just add the attribute -->
<abbr data-tooltip="Cascading Style Sheets">CSS</abbr>
<button data-tooltip="Delete this item permanently">🗑</button>
/* CSS: one rule to rule them all */
[data-tooltip] {
position: relative;
cursor: help;
}
[data-tooltip]::before {
content: attr(data-tooltip); /* pulls value from HTML attribute */
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
background: #0f172a;
color: #e2e8f0;
font-size: 0.78rem;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
max-width: 220px;
white-space: normal;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 1000;
}
[data-tooltip]::after {
content: '';
position: absolute;
bottom: calc(100% + 3px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #0f172a;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
[data-tooltip]:hover::before,
[data-tooltip]:hover::after,
[data-tooltip]:focus::before,
[data-tooltip]:focus::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
The magic is content: attr(data-tooltip), which pulls the string directly from the HTML attribute. This keeps your CSS generic and your HTML declarative. You can use our Live HTML/CSS/JS Editor to experiment with variations before adding them to your project.
Tooltip Placement Variants
Tooltips often need to appear on different sides of the trigger. Here's a clean set of placement modifiers:
/* Default: top */
[data-tooltip-pos="bottom"]::before {
bottom: auto;
top: calc(100% + 8px);
transform: translateX(-50%) translateY(-4px);
}
[data-tooltip-pos="bottom"]:hover::before {
transform: translateX(-50%) translateY(0);
}
[data-tooltip-pos="right"]::before {
bottom: auto;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%) translateX(-4px);
}
[data-tooltip-pos="right"]:hover::before {
transform: translateY(-50%) translateX(0);
}
[data-tooltip-pos="left"]::before {
bottom: auto;
right: calc(100% + 8px);
left: auto;
top: 50%;
transform: translateY(-50%) translateX(4px);
}
[data-tooltip-pos="left"]:hover::before {
transform: translateY(-50%) translateX(0);
}
Method 3: The Native HTML Popover API
The Popover API is a newer browser-native feature that handles popovers declaratively in HTML — no JavaScript required for basic usage. It manages focus trapping, light-dismiss (click outside to close), and stacking contexts automatically.
Basic Popover Setup
<button popovertarget="my-popover">Show Info</button>
<div id="my-popover" popover>
<h3>Details</h3>
<p>This is a popover with richer content than a tooltip.
It can contain links, buttons, and any HTML.</p>
<button popovertarget="my-popover" popovertargetaction="hide">Close</button>
</div>
/* Style the popover */
[popover] {
position: fixed; /* Popovers are in the top layer */
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 20px;
max-width: 320px;
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
background: #fff;
}
/* Animate with @starting-style and allow-discrete */
[popover] {
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
opacity: 1;
transform: translateY(0);
}
[popover]:not(:popover-open) {
opacity: 0;
transform: translateY(8px);
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(8px);
}
}
Anchor Positioning (CSS Anchor Positioning)
One of the most powerful new features alongside the Popover API is CSS Anchor Positioning, which lets you tether a popover's position directly to its trigger element without JavaScript calculations:
#my-trigger {
anchor-name: --trigger-anchor;
}
#my-popover {
position: absolute;
position-anchor: --trigger-anchor;
top: anchor(bottom);
left: anchor(center);
transform: translateX(-50%);
margin-top: 8px;
}
This eliminates entire libraries that previously solved the "keep my tooltip pointing at the right element" problem. Browser support is growing rapidly — check caniuse.com for current status.
Accessibility Checklist for Tooltips
- aria-describedby — Link the tooltip element's ID to the trigger. Screen readers will announce the tooltip text as a description.
- role="tooltip" — Mark tooltip containers so assistive technologies identify them correctly.
- Focus visibility — Always show tooltips on
:focusor:focus-visible, not just:hover. - Keyboard dismissal — Allow Escape key to hide tooltips (requires small JS for rich popovers — the Popover API handles this natively).
- Sufficient contrast — Tooltip text must meet 4.5:1 contrast ratio against its background.
- Don't hide essential info — Tooltips should supplement, never replace, visible labels.
Rich Tooltip with a Delay
A common UX improvement is adding a short delay before a tooltip appears to avoid flickering as users move the mouse across the screen:
.tooltip {
/* ... previous styles ... */
transition-delay: 0s; /* No delay on hide */
}
.tooltip-trigger:hover .tooltip {
opacity: 1;
transition-delay: 0.4s; /* 400ms delay before showing */
}
The delay only applies when showing (the :hover state), not when hiding, which feels natural. Use our CSS Formatter to keep your tooltip stylesheet clean and readable as it grows.
Putting It All Together: Complete Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tooltip Demo</title>
<style>
body { font-family: system-ui; display: flex; gap: 20px; padding: 60px; }
[data-tooltip] {
position: relative;
cursor: help;
}
[data-tooltip]::before {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
background: #1e293b;
color: #f1f5f9;
font-size: 0.78rem;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease 0.3s, transform 0.2s ease 0.3s;
z-index: 100;
}
[data-tooltip]:hover::before,
[data-tooltip]:focus::before {
opacity: 1;
transform: translateX(-50%) translateY(0);
transition-delay: 0s;
}
</style>
</head>
<body>
<button data-tooltip="Copy to clipboard">📋 Copy</button>
<button data-tooltip="Download as PDF">⬇️ Download</button>
<abbr data-tooltip="Cascading Style Sheets">CSS</abbr>
</body>
</html>
When to Use Tooltips vs Popovers
The distinction is important for both UX and accessibility:
- Tooltip — Short, single-line text. Appears on hover/focus. Non-interactive. Use for icon labels, keyboard shortcuts, brief explanations.
- Popover — Richer content — HTML, buttons, links. User-triggered. Can be dismissed. Use for settings panels, contextual help, date pickers, dropdown menus.
The native Popover API is specifically designed for the popover use case, not for simple text tooltips. Keep CSS-only tooltips for simple labels and reserve the Popover API for interactive panels.