Tooltip
A small hint shown on hover or focus. Positioning, hover-intent and a11y come from Tippy.js in headless mode - Koala owns the bubble's markup, styled with our tokens. The one component built on a non-Radix primitive.
Installation#
Usage#
import { FloppyDisk } from "@phosphor-icons/react"
import { Button } from "@/components/ui/button"
import { Tooltip } from "@/components/ui/tooltip"
export function Example() {
return (
<Tooltip content="Save changes">
<Button iconOnly aria-label="Save changes">
<FloppyDisk />
</Button>
</Tooltip>
)
}Variants#
variant sets the content shape. text (the default) is a short label on an inverted chip, the page's ink as its ground, so it reads at a glance and never passes for a card. Switch to graph when the content is a multi-line card (a title plus stats, a picture, a route): it moves onto the popover surface with Popover's border and shadow, drops the centering for a left-aligned block and adds 8px 12px padding so the rows aren't cramped against the edge.
Data breakdown#
For a richer graph tooltip, compose the breakdown parts instead of hand-rolling markup: TooltipHeader / TooltipValue for the title and total, TooltipSeparator for the rule, and TooltipSection + TooltipStat for tone-railed rows of label / percent / value. The rail color is a token role via tone (brand · info · success · warning · destructive · neutral), so it tracks the theme - no raw colors. Mark sub-lines with indent.
Rich card#
The graph variant grows as far as a preview card: a picture, a mark, a title with its status, and a few facts, built from the same parts as the data card. It stays a tooltip as long as it only shows: it opens on hover and focus and holds nothing to click. The moment it needs a button, a form or more than one link, it is a Popover. Keep the picture's corner concentric with the card (8px inside a 16px card) and give it the hairline image outline.
Placement#
placement takes any Tippy placement (each also accepts a -start / -end suffix). The bubble grows out of the edge nearest the trigger.
The bubble is positioned fixed, so one that lands past the edge of the screen is clipped there instead of widening the page: a phone never scrolls sideways because of a tooltip. While it is up it follows its trigger when the trigger moves (a drawer sliding in, a row that grows), rather than staying where the trigger was when it opened.
With an icon#
content is any node - a leading glyph sits a 4px gap from the label. Pair it with an icon-only button that would otherwise have no visible name.
Interactive#
interactive keeps the tooltip open while the pointer is over it, so it can hold a link or action. Tune the hover-intent with delay ([open, close] in ms).
Controlled#
Pass open to drive the bubble yourself: the hover and focus triggers switch off, and it shows exactly while open is true. Use it for a hint that answers an action rather than a hover, like a copy confirmation.
Anchored to a point#
Some things have no element to hang a tooltip on: a pin drawn on a map, a point on a chart, whatever sits under the cursor on a canvas. Give the tooltip an anchor instead of a child, the point in viewport coordinates (or a box, with width and height), and drive open from your own hover logic. Moving the anchor re-positions the open bubble without re-mounting it, and the bubble takes no pointer events, so it never steals the pointer it follows.
Group#
Wrap a set of tooltips in TooltipGroup to share a single bubble that glides from trigger to trigger - a Tippy singleton under the hood. Instead of each tooltip fading out and then in with its own delay, the bubble repositions instantly with a CSS transform transition. Use this for toolbars, avatar stacks, or any dense row of labeled controls.
The delay and offset props on individual Tooltips inside a group are ignored - set them once on TooltipGroup.
On form inputs#
Wrap an InputSuffixButton in a Tooltip to surface field hints - password rules, format requirements, contextual help - without cluttering the label. Place the button directly inside InputRoot (not inside InputSuffix, which is aria-hidden) so keyboard users can reach it.
Inside a dialog#
Tippy appends its bubble to document.body, so tooltips always render above the dialog's stacking context - no extra z-index or portal config required. A small icon next to a form label is the most common pattern.
Focus opens a tooltip only when it is keyboard focus. A dialog or drawer opened with a click or a tap moves focus onto its first control by script, and that shows no bubble; opened from the keyboard, the focused control shows its hint like any other. To drop focus hints altogether, as the help icons below do, pass trigger="mouseenter".
Keyboard shortcut hint#
Include a Kbd in content to pair an action name with its shortcut. On the inverted chip the key is built from the chip's own ink, so the same Kbd reads on both variants. Combine with TooltipGroup on a toolbar so the bubble glides between buttons instead of re-fading.
Transitions#
Both transition styles are interruptible CSS transitions - never keyframe animations - so re-hovering mid-exit retargets smoothly without resetting from the beginning.
Shift-away (standalone)
The standalone Tooltip uses a shift-away effect: on exit the bubble scales to 95 % and slides a few pixels away from the trigger. The direction is derived from data-placement (stamped at mount from Popper.js), so the bubble always retreats toward its origin edge regardless of the configured placement.
// Opacity + scale (95→100) + directional slide driven by data-placement
"data-[state=hidden]:opacity-0 data-[state=visible]:opacity-100"
"data-[state=hidden]:scale-95 data-[state=visible]:scale-100"
// Retreats toward the nearest trigger edge on exit:
"data-[placement^=top]:data-[state=hidden]:-translate-y-1"
"data-[placement^=bottom]:data-[state=hidden]:translate-y-1"
"data-[placement^=left]:data-[state=hidden]:-translate-x-1"
"data-[placement^=right]:data-[state=hidden]:translate-x-1"Glide (singleton / group)
Inside a TooltipGroup the shared bubble uses the singleton transition variant: opacity-only on the bubble element (no scale, so it doesn’t shrink between consecutive triggers). Spatial motion is handled entirely by Tippy’s moveTransition - a single transform transition that drives the positional glide.
// Bubble: opacity-only (no scale jitter as it glides between triggers)
"data-[state=hidden]:opacity-0 data-[state=visible]:opacity-100"
"scale-100" // fixed - spatial motion is not on the bubble element
// Positional glide - set on the Tippy singleton source:
moveTransition="transform var(--duration-base) var(--ease-out)"Base transition (shared)
Both variants extend the same base transition declaration. Only specific properties are named - never transition: all - so unrelated properties (like background-color) aren’t accidentally caught and the browser can composite the animation on the GPU.
// Tailwind class: "transition duration-fast ease-out"
// Expands to:
// transition-property: opacity, transform, scale, filter;
// transition-duration: var(--duration-fast); /* 160 ms */
// transition-timing-function: var(--ease-out); /* cubic-bezier(0.23, 1, 0.32, 1) */Speed & delay#
The bubble always animates at duration-fast (160 ms) - fast enough not to block the user, slow enough to feel intentional rather than a jump cut. The three motion tokens side by side:
duration-fast
160ms
duration-base
300ms
duration-slow
450ms
The easing curve - for both the bubble animation and the singleton glide - is ease-out: an aggressive initial velocity that decelerates sharply. It reads as snappy without being harsh, and is the right curve for any short UI enter/exit:
ease-out
UI enter / exit
ease-in-out
On-screen movement
ease-drawer
Sheets / drawers
ease-sine
Oscillation
Hover-intent is separate from animation speed and is controlled by the delay prop - an [open, close] tuple in ms. Animation speed stays at 160 ms regardless.
// Default - 150 ms open intent, instant close
<Tooltip delay={[150, 0]} content="…">…</Tooltip>
// Interactive - give the user time to reach the bubble before it closes
<Tooltip interactive delay={[150, 80]} content={<a href="…">Link</a>}>…</Tooltip>
// Instant open (kiosk / touch UI with no hover ambiguity)
<Tooltip delay={0} content="…">…</Tooltip>
// Group - delay is set once and shared by all children. The 200 ms close delay is the grace
// period that lets the pointer reach the next trigger while the bubble is still up.
<TooltipGroup delay={[150, 200]}>
<Tooltip content="Save">…</Tooltip>
<Tooltip content="Copy">…</Tooltip>
</TooltipGroup>