How to Build a Tailwind Tooltip With group-hover
The tooltip is a plain span that stays in the DOM the whole time. The wrapper carries group, the span is absolutely positioned, and hovering the wrapper is what makes the span visible.
It behaves like any tooltip in UI design. A short label appears next to a control and goes away once attention moves elsewhere.
Start at the wrapper and work outward.
- Wrap everything in a div with
relative inline-block group. - Add the trigger, usually a button, as the first child.
- Add the tooltip as a sibling span with
absolute, plus bottom-full left-1/2 -translate-x-1/2 for a top placement. - Hide it with
invisible opacity-0. - Reveal it with
group-hover:visible group-hover:opacity-100. - Add
transition-opacity so the reveal is a fade instead of a snap.
Put together, the markup looks like this:
<div class="relative inline-block group">
<button type="button" class="rounded bg-slate-800 px-3 py-2 text-sm text-white">
Save
</button>
<span
class="invisible absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-slate-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
>
Save changes
</span>
</div>
Tailwind’s documentation uses the same invisible plus group-hover:visible pairing in its named-group example (Tailwind CSS v4.3 docs).
This version reacts to hover only. Keyboard focus and touch need extra classes.
A short label is fine with whitespace-nowrap. Longer text wants w-max max-w-xs instead, so the box wraps at a readable width.
z-10 lifts the tooltip over its neighbors, but it can’t get out of a parent that clips overflow. More on that near the end.
You’ll also see pointer-events-none added to stop the tooltip catching the pointer and flickering. It works, but nobody can move onto the tooltip to read it anymore, and the accessibility section comes back to that.
Tailwind Tooltip Placement and Arrow Classes
Each side comes down to two moves. Push the tooltip off the trigger’s edge, then center it along the other axis.
Placement classes per side
- A top tooltip takes
bottom-full left-1/2 -translate-x-1/2 - Below the trigger, it’s
top-full left-1/2 -translate-x-1/2 - To the left, use
right-full top-1/2 -translate-y-1/2 left-full top-1/2 -translate-y-1/2 handles the right side
Spacing is where most snippets go wrong. A margin like mb-2 leaves a strip that belongs to neither the trigger nor the tooltip, so the pointer drops the group while crossing it.
Padding on the tooltip wrapper fixes that (pb-2 for a top tooltip). The strip then sits inside the group and hover survives the trip.
Arrow as rotated square or pseudo-element
Tailwind’s documentation favors a real element over a pseudo-element for most decorative shapes, and keeps before: and after: for content that should stay out of the DOM (Tailwind CSS v4.3 docs).
For an arrow, that means a rotated square. Give it absolute size-2 rotate-45 and the same background color as the tooltip box.
The edge class depends on the side. A top tooltip gets top-full on the arrow, a bottom one gets bottom-full, a left one left-full and a right one right-full. Vertical placements pair that with left-1/2 -translate-x-1/2, and horizontal ones with top-1/2 -translate-y-1/2.
Here is a top tooltip with the padded wrapper and the arrow combined:
<span class="invisible absolute bottom-full left-1/2 z-10 -translate-x-1/2 pb-2 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100">
<span class="relative block whitespace-nowrap rounded bg-slate-900 px-2 py-1 text-xs text-white">
Save changes
<span aria-hidden="true" class="absolute left-1/2 top-full size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-slate-900"></span>
</span>
</span>
The arrow carries no meaning, so it gets aria-hidden="true".
The pseudo-element route skips the extra span and builds a border triangle with before: classes, the same trick behind CSS arrows in plain stylesheets. Tailwind adds content: '' to before: automatically, so only the border classes are needed: before:absolute before:top-full before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-slate-900.
How Do You Animate a Tailwind Tooltip and Style It for Dark Mode?
The fade comes from transition-opacity on the base state. Add group-hover:delay-300 if the reveal should wait, and use dark: variants for color. Tailwind’s transition-property reference gives 150 ms as the default duration, which already suits a label this small.
A plain invisible opacity-0 transition-opacity setup fades in but cuts out. On mouse leave, visibility flips to hidden at once, so the opacity fade runs on an element nobody can see.
Listing visibility in the transition solves it: transition-[opacity,visibility]. MDN documents that visibility interpolates between visible and not-visible as a discrete step, so the element stays visible for the whole transition as long as one end of it is visible.
That’s also why invisible beats hidden here. Tailwind’s transition-discrete utility is built for hidden-to-block changes, and a tooltip gains nothing from the extra class.
group-hover:delay-300 postpones only the reveal. The hide runs without delay, because the state being entered supplies the delay value. Tailwind’s transition-delay docs apply delays conditionally the same way, with variants such as motion-reduce:delay-0.
People who ask their system for less motion get motion-reduce:transition-none, which removes the fade since the variant maps to prefers-reduced-motion: reduce.
Slide-in effects changed in v4. Translate, scale and rotate are built on individual CSS properties now, so transition-[opacity,transform] no longer animates them. List translate instead, as in transition-[opacity,translate,visibility] (Tailwind CSS upgrade guide).
The dark: variant follows prefers-color-scheme by default, per Tailwind’s variant reference. A class or data-attribute toggle needs a custom variant registered with @custom-variant.
I’d invert the tooltip rather than reuse the page palette. So bg-slate-900 text-white in light mode, and dark:bg-slate-100 dark:text-slate-900 in dark mode.
Tooltip text is small text, so the color contrast of each pair has to clear 4.5:1. W3C’s Understanding document for success criterion 1.4.3 confirms the rule covers text that appears on hover or keyboard focus.
Does a Tailwind Tooltip Work on Touch Screens and Keyboards?
Not by default. Tailwind CSS v4 applies the hover variant only on devices that support hover, so hover-only tooltips stay closed on touch screens, and keyboard users see nothing until focus variants are added.
The v4 upgrade guide describes the change. hover now compiles inside a media query on the hover feature, @media (hover: hover), and Tailwind’s own variant reference for v4.3 lists it that way.
Pull request 8394 in the tailwindlabs repository introduced the behavior behind a future flag in v3 and called it a breaking change for menus that open on hover. It names hidden group-hover:block as the pattern that stops working.
Read together, the two sources describe a regression that tutorials rarely mention. Under v3, a tap left a sticky hover state that opened tooltips on phones. Under v4, the same markup stays shut.
The upgrade guide’s advice is to treat hover as an enhancement, because touch devices can’t truly hover. A hover-only tooltip can decorate. It shouldn’t be the only place a fact lives.
Focus variants sit outside the hover media query (focus compiles to a plain &:focus selector), so a keyboard path can be added without touching hover.
There’s a snag with group-focus, though. It fires only when the element carrying the group class is itself focused, which is why Tailwind’s older docs put group on the button. A button inside a wrapper needs group-focus-within instead, which matches when the wrapper or any descendant has focus.
| Problem | Fix | Trade-off |
|---|
| Tooltip never opens on tap | Show required text by default, keep the tooltip as extra | Needs layout room for visible text |
| Tooltip ignores keyboard focus | Add group-focus-within:visible and group-focus-within:opacity-100 | Also opens after a mouse click focuses the button |
| Old tap-opens-tooltip behavior is needed | Override with @custom-variant hover (&:hover) | Brings sticky hover back on touch |
To limit the reveal to keyboard focus, swap in group-has-[:focus-visible], which checks the focus-visible state of any descendant.
Touch needs a click-driven toggle in place of hover.
- Give the button
peer and aria-expanded="false" - Style the tooltip, placed after the button, with
peer-aria-expanded:visible peer-aria-expanded:opacity-100 - Flip the attribute in a click handler
People usually call that a toggletip. It stays open until the user toggles it again, so it also needs an Escape or outside-click handler to close.
Tailwind Tooltip Accessibility: What WCAG 1.4.13 and ARIA Require
Level AA is where WCAG 1.4.13 sits, and it asks for hover or focus content that can be dismissed, hovered and kept on screen. The W3C ARIA Authoring Practices pattern covers the markup, with role="tooltip" on the container and aria-describedby on the trigger.
The W3C’s Understanding document for the criterion (updated 12 July 2026) lists custom tooltips among the content it covers. Out of the box, a plain group-hover tooltip satisfies one of the three conditions.
| Condition | Plain group-hover result | Fix |
|---|
| Dismissible | Fails: CSS alone has no Escape handler | Keydown script that sets the hidden attribute |
| Hoverable | Fails with a margin gap or pointer-events-none | Padded wrapper, no pointer-events-none |
| Persistent | Passes while hover or focus lasts | Add group-focus-within, avoid timed hiding |
Hoverable is the row people fail by accident. The W3C lists hover content that can’t itself be hovered as failure F95.
Dismissible has two exemptions, for input error messages and for content that doesn’t obscure or replace anything else. A tooltip floating over empty space can claim it, but most sit on top of something, so plan for Escape.
The ARIA half of the pattern is two attributes. The tooltip gets role="tooltip" and a unique id, and the trigger points at that id with aria-describedby.
<div class="relative inline-block group" data-tooltip-group>
<button type="button" aria-describedby="save-tip" class="rounded bg-slate-800 px-3 py-2 text-sm text-white">
Save
</button>
<span
id="save-tip"
role="tooltip"
class="invisible absolute bottom-full left-1/2 z-10 -translate-x-1/2 pb-2 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100"
>
<span class="block whitespace-nowrap rounded bg-slate-900 px-2 py-1 text-xs text-white">Save changes</span>
</span>
</div>
The APG page marks the pattern as work in progress without task force consensus. Its notes are still specific. Focus stays on the trigger, the tooltip never takes it, and the tooltip closes on blur or Escape.
A few lines of script finish the job. Escape hides every tooltip, and hover or focus on the wrapper brings the tooltip back.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
document.querySelectorAll('[role="tooltip"]').forEach((tip) => {
tip.hidden = true;
});
});
document.querySelectorAll('[data-tooltip-group]').forEach((group) => {
const tip = group.querySelector('[role="tooltip"]');
['mouseenter', 'focusin'].forEach((type) => {
group.addEventListener(type, () => { tip.hidden = false; });
});
});
The tooltip carries no display utility, so the native hidden attribute wins in v3 and v4 alike. Tailwind v4 also made the attribute outrank display classes such as block and flex (Tailwind upgrade guide).
One limit worth knowing: a tooltip can’t hold links or buttons. The APG says a hover that contains focusable elements should be built as a non-modal dialog, and the W3C adds that more predictable ways of putting content on a page usually exist.
Pure CSS, daisyUI, Flowbite or Floating UI for a Tailwind Tooltip
For a handful of tooltips with room around them, plain utility classes are enough. daisyUI makes sense when the project already runs it and the text is plain. Flowbite fits if script-managed triggers are acceptable, and Floating UI or Radix are the pick when collision handling and Escape need to come built in.
| Method | Dependency | Placement and collisions | Keyboard and Escape |
|---|
| Pure utilities | None | Hand-set classes, no flip | group-focus-within plus a small script |
| daisyUI | Tailwind plugin | Four sides, three alignments, no flip option documented | Docs mention hover only |
| Flowbite | Flowbite JS, Popper.js arrow markup | Four sides via data attribute | Docs say hover or focus |
| Floating UI | JavaScript library | offset, flip, shift and arrow middleware | You wire events or hooks |
| Radix UI and shadcn/ui | React package (shadcn/ui can be built on Radix UI, Base UI or React Aria) | Side prop | Opens on focus, closes on Escape |
Reach for a library last. Installing a kit to label a row of icon buttons trades a few utility classes for a dependency, and with Flowbite for a script tag too.
Pure CSS with utility classes
Utility classes do what a plain CSS tooltip does with a hand-written stylesheet, minus the stylesheet. Nothing to install and nothing to keep updated.
You get no dependency, no script for the basic version, and behavior you can read straight from the HTML. What you give up is collision handling, and Escape dismissal still needs a script.
Component kits (daisyUI and Flowbite)
daisyUI gives you the tooltip class and a data-tip attribute. That produces a tooltip with four placements (top is the default), start, center and end alignment, a tooltip-open modifier to force it open, and color modifiers. The text lives in an attribute unless you add the tooltip-content part, and the docs describe hover only, so test keyboard focus before shipping.
Flowbite works through data-tooltip-target, which wires a trigger to a tooltip element whose markup already carries role="tooltip". Attributes switch placement and a hover or click trigger. None of it runs until the Flowbite script is on the page.
Setup differs in v4. daisyUI installs with npm i -D daisyui@latest and one line after the Tailwind import, @plugin "daisyui"; (daisyUI docs, v5.7.50). Flowbite’s v4.0 quickstart adds @import "flowbite/src/themes/default";, @plugin "flowbite/plugin";, an @source line for its package folder, and the flowbite.min.js script.
Floating UI and Radix-based tooltips
Radix’s TooltipProvider waits 700 ms by default before opening a tooltip, according to the Radix UI documentation, and the delay can be changed globally or per tooltip.
Radix opens on focus or hover and closes on Escape or when the trigger is activated. shadcn/ui offers its tooltip as a component built on that kind of primitive (its current docs cover Radix UI, Base UI and React Aria versions). Add it with the CLI, mount TooltipProvider once at the app root, and set placement with the side prop.
Escape, focus and delay behavior arrive built in with Radix. Floating UI is the placement tool, with offset, flip, shift and arrow middleware plus React and Vue bindings next to the plain DOM package. Both add JavaScript and take placement out of the class list, and Radix and shadcn/ui only serve React projects.
One trap sits inside the Floating UI docs. The tutorial example lists flip() before shift(), while the combining section of the flip page puts shift() first for placements such as top and flip() first for edge-aligned ones such as top-start.
The flip page says its recommended order gives the most expected positioning whatever placement is used. Follow it over the tutorial.
When Does a Pure CSS Tailwind Tooltip Break?
Clipping comes first. An ancestor with overflow-hidden, overflow-auto or overflow-scroll cuts an absolutely positioned tooltip off at its own edge, and table cells, cards and scrolling sidebars are the usual culprits.
Screen edges are next. The side is fixed when you write the classes, so nothing moves the tooltip when space runs out.
Stacking is the quiet one. z-10 only competes inside one stacking context, so a tooltip inside a transformed or positioned parent can still render under a neighboring section.
The edge case is the one no utility class solves, because a class can’t measure how much room is left in the viewport. Floating UI’s flip() exists for exactly that. It changes the placement to keep the floating element in view, flipping to the opposite side by default (Floating UI docs).
A portal handles clipping and stacking. Radix renders tooltip content through Tooltip.Portal, outside the trigger’s DOM tree, so ancestor overflow rules no longer cut it.
CSS anchor positioning moves the placement job into CSS, with anchor-name on the trigger and position-anchor on the tooltip. Whether it’s safe to rely on depends on which source you read.
- web.dev said in January 2026 that Firefox 147 made CSS anchor positioning Baseline Newly available.
- The same web.dev page, fetched in October 2026, has a position-anchor support table listing Chrome 151, Edge 151, Firefox 151 and Safari 27, where an earlier snapshot listed Chrome 125, Firefox 147 and Safari 26. MDN’s compat data marks the earlier versions as partial only because the property’s initial value changed.
- OddBird said in April 2026 that anchor positioning was not Baseline yet.
- The web-features data behind Baseline badges now lists anchor positioning as not Baseline, after more anchor properties were added to the feature in May 2026.
The web.dev post warns that its MDN-sourced browser data can trail recent releases. Issue 3558 in the web-features repository explains that the Baseline status was then computed from anchor-name, position-anchor and anchor() only, so different dates and different property sets explain the gap.
Tailwind’s v4 upgrade guide sets its own browser floor at Safari 16.4, Chrome 111 and Firefox 128. Every anchor positioning version quoted above (Chrome 125, Firefox 147 and Safari 26 for anchor-name) is higher, so a project that supports v4’s full range still has a cross-browser compatibility gap to cover.
What I’d do is keep the group-hover tooltip as the baseline, layer anchor rules behind Tailwind’s supports-[...] variant (which generates an @supports rule), and bring in Floating UI only where the fallback matters.
Tailwind Tooltip FAQ
What separates a tooltip from a toggletip or a popover?
A tooltip opens on hover or keyboard focus, holds a short non-interactive label, and never takes focus. A toggletip opens on click or tap from a button.
Once the content includes links or buttons, you’re in popover territory, which the W3C ARIA Authoring Practices assigns to the non-modal dialog pattern.
Is the native title attribute enough?
Only for a plain browser-drawn hint. The browser controls how title tooltips look and behave, so Tailwind classes can’t restyle them or change their delay.
WCAG 1.4.13 exempts them for that reason, since the user agent controls the presentation (W3C).
How do you build a Tailwind tooltip in React?
Reuse the same markup with className in place of class. A hover-and-focus version built on group-hover needs no state at all.
Escape dismissal or a click toggle needs a useState hook, or the shadcn/ui Tooltip component, which is built on Radix UI, Base UI or React Aria depending on the version you add.
Can a Tailwind tooltip work on a disabled button?
Yes, with a wrapper. The shadcn/ui docs show the fix, which is to wrap the button in a span and attach the tooltip to the span.
Disabled buttons can’t take keyboard focus, so aria-disabled keeps the trigger reachable when keyboard users need the tooltip text.
What to Test Before a Tailwind Tooltip Ships
Run Tab focus, Escape, pointer movement onto the tooltip, touch emulation and 200% zoom, in that order.
Tab focus goes first because every other check needs a tooltip that opens. Escape and the pointer path come next, before any visual polish, and touch plus zoom wait until the layout stops changing.
Passing all five still leaves edge collisions unsolved. Staying with utility classes plus one Escape script means reviewing placement by hand for every tooltip near a screen edge.
Position verified 29 October 2026. The advice changes once CSS anchor positioning is Baseline again in the web-features data, because native placement would remove that review cost.
Other hover and focus patterns follow the same rules, and a web accessibility checklist covers them in more depth.