A CSS tooltip is a small informational label that appears near a UI element when the user hovers over it or focuses it with the keyboard. Unlike JavaScript-powered tooltip libraries, pure CSS tooltips require no dependencies, add minimal page weight, and perform with zero JavaScript execution cost. They are appropriate for supplementary labels and hints — short text that explains what a button or icon does. For richer content (multiple sentences, links, interactive elements), a JavaScript-managed popover is the correct choice.
The fundamental challenge with tooltip visibility is that display: none cannot be transitioned. Elements that are display:none are removed from the rendering tree instantly — there is no "halfway between none and block" for the browser to interpolate. The solution is to use opacity and visibility together. Opacity transitions from 0 to 1 smoothly. Visibility distinguishes between hidden (non-interactive, not visible but still in layout) and visible, and critically, it accepts a transition delay — allowing you to make the element non-interactive only after the opacity fade-out is complete.
The pattern works like this: on the base state, set opacity: 0; visibility: hidden with a transition that fades opacity over 200ms, and delays the visibility change by the same 200ms so the element doesn't become non-interactive mid-animation. On the hover/focus state, set opacity: 1; visibility: visible with the same opacity transition but zero delay on visibility so it becomes interactive immediately when appearing. This produces a smooth fade-in on show and a smooth fade-out on hide, with the element always correctly interactive or non-interactive.
The trigger element needs position: relative so the absolutely-positioned tooltip is contained within its coordinate system. The tooltip itself — whether a pseudo-element or a child span — uses position: absolute to place it relative to the trigger. For a tooltip above the trigger, bottom: calc(100% + 10px) places it 10px above the trigger's top edge. For below, top: calc(100% + 10px). For right, left: calc(100% + 10px). For left, right: calc(100% + 10px). The gap value (10px in these examples) controls the space between the tooltip and the trigger.
Centering is handled differently by direction. A tooltip above or below the trigger centers horizontally with left: 50%; transform: translateX(-50%). A tooltip to the left or right of the trigger centers vertically with top: 50%; transform: translateY(-50%). The transform-based centering is necessary because you can't use margin: auto on absolutely positioned elements with explicit directional offsets.
Adding a subtle directional slide to the tooltip's appearance makes it feel more responsive and polished. The technique is to give the tooltip a small offset in the transform value on the hidden state — for example, transform: translateX(-50%) translateY(4px) for a tooltip appearing above the trigger — and transition it to translateX(-50%) translateY(0) on the visible state. The tooltip appears to slide 4px upward into position as it fades in. The direction of the slide should match the tooltip's position: upward for tooltips above, downward for tooltips below, and sideways for left/right tooltips. Keep the distance small — 4-6px is enough to feel responsive without being distracting.
The most maintainable tooltip implementation uses CSS to read the tooltip content directly from an HTML attribute via the content: attr(data-tooltip) declaration on the pseudo-element. This means you set data-tooltip="Your hint text" on the trigger element in HTML, and a single CSS rule provides the tooltip for every element with that attribute — no separate CSS per element. The attribute value appears as text content in the pseudo-element. The limitation is that attr() can only be used in the content property, so it works with text-only tooltips. For tooltips containing HTML (formatted text, icons, links), you need a visible child element rather than a pseudo-element.
Tooltips that appear instantly on hover can be annoying — any time the user moves the mouse across the interface, tooltips flash on every element they pass over. Adding a 300-500ms delay before showing prevents this: the tooltip only appears if the user genuinely pauses on an element. The delay is implemented by adding a matching delay to the opacity and visibility transitions in the hidden-to-visible direction. Crucially, the hide direction should have no delay — tooltips should disappear immediately when the user moves away. This asymmetric delay (delayed show, instant hide) is the correct default for most UI contexts.
A hover-only tooltip is inaccessible to keyboard users. The fix is to include :focus-within or :focus-visible alongside :hover in every selector that shows the tooltip. :focus-visible is preferred because it only shows the tooltip when the element receives keyboard focus (tab navigation), not when it receives mouse focus — which prevents the tooltip from appearing when a user clicks the trigger with a mouse, a situation where the tooltip is usually redundant. Screen reader users benefit from the tooltip text being made available as an accessible description via aria-describedby pointing to the tooltip element's ID, and the tooltip element itself should have role="tooltip".
The most common tooltip bug is the tooltip being clipped by a parent element with overflow: hidden. An absolutely positioned element is constrained by the nearest ancestor with a non-static position and within the ancestor's overflow bounds. If any ancestor between the trigger and the document root has overflow: hidden, the tooltip will be clipped at that ancestor's edge. The solutions are: remove the overflow:hidden from the ancestor if it isn't needed for layout reasons, move the tooltip outside the problematic ancestor using JavaScript repositioning, or use a JavaScript tooltip library that appends tooltip elements to the document body outside the DOM hierarchy.
Z-index conflicts are the other common issue. Tooltips need a sufficiently high z-index to appear above all other content. The challenge is that z-index only works within the same stacking context. If the trigger is inside an element that creates a new stacking context (via transform, filter, will-change, isolation: isolate, or similar), the tooltip's z-index is scoped to that context and may still appear behind elements from other stacking contexts. This is another case where JavaScript positioning (appending to the body) solves the problem definitively.
Tooltips are the right choice for short supplementary text that restates or clarifies what a UI element does — "Copy to clipboard," "Delete item," "Toggle dark mode." They are not the right choice for essential information (users without mouse access may miss it), interactive content (links and buttons inside a tooltip are inaccessible), or long explanations (they should be inline text or a help panel). When the content is richer than a single short phrase, or when it needs to remain visible after the user moves the mouse, a popover is the appropriate pattern. The HTML popover attribute (now natively supported in all modern browsers) provides a declarative way to build popovers without JavaScript for the show/hide behaviour.
display: none removes the element from the rendering tree instantly and cannot be transitioned. opacity: 0 makes the element invisible but keeps it in the layout, and transitions smoothly. visibility: hidden makes the element non-interactive (pointer events don't fire on it) while keeping it in the layout. Together they produce the same end state as display:none but with the ability to animate in and out.
Use content: attr(data-tooltip) in your pseudo-element CSS and add data-tooltip="Your text here" to each trigger element in HTML. This reads the attribute value at render time and displays it as the tooltip content. You can change the tooltip text by changing the data attribute value via JavaScript without touching your CSS.
Not reliably. Touch devices have no hover state — a tap fires a simulated click, not a hover. Many mobile browsers will show a hover state briefly on first tap, then proceed to the click, but the behaviour is inconsistent across browsers and versions. For touch-friendly tooltips, use a tap-to-toggle approach with JavaScript, or use the native popover attribute which is designed to work across both mouse and touch interactions.
The cleanest CSS-only fix is to remove overflow: hidden from the ancestor if it isn't needed. If the overflow is required for layout (clipping content, hiding scrollbars), you need to move the tooltip outside the overflow boundary. This typically requires JavaScript to position the tooltip by appending it to the document body and calculating its position relative to the trigger using getBoundingClientRect().
The tooltip element should have role="tooltip" and an id. The trigger element should have aria-describedby pointing to the tooltip's id. This allows screen readers to announce the tooltip text as additional description when the trigger receives focus. Without this, screen reader users hear only the trigger's label and have no access to the tooltip content.
Add a transition-delay matching value to the opacity and visibility transitions in the hidden-to-visible direction only. Use 300-500ms for the show direction, and no delay (or 0ms) for the hide direction. The asymmetric delay prevents tooltips from flashing when the cursor moves across the interface, while still ensuring they hide immediately when the cursor leaves.