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 "koala-icons"
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>
)
}Anatomy#
import {
Tooltip,
TooltipGroup,
TooltipHeader,
TooltipHeaderText,
TooltipTitle,
TooltipDescription,
TooltipValue,
TooltipSeparator,
TooltipSection,
TooltipStat,
} from "@/components/ui/tooltip"
<TooltipGroup>
<Tooltip content="...">
<button />
</Tooltip>
</TooltipGroup>
<Tooltip
variant="graph"
content={
<>
<TooltipHeader>
<TooltipHeaderText>
<TooltipTitle />
<TooltipDescription />
</TooltipHeaderText>
<TooltipValue />
</TooltipHeader>
<TooltipSeparator />
<TooltipSection>
<TooltipStat label="..." value="..." />
</TooltipSection>
</>
}
>
<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.
Around an icon-only Button#
An iconOnly Button already shows its aria-label as a tooltip. Wrap it in your own Tooltip (for a keycap, a longer hint, another placement) and the Button's hint stands down, so there is one bubble, never one above and one below. The same holds through an asChild trigger such as PopoverTrigger, for any button inside the wrapped element, and for the hints other components add on their own (ToggleGroupItem, ToolbarButton, DockItem, SidebarItem, SelectItem). No tooltip={false} needed.
Building a component that adds a hint of its own? Pass auto to that Tooltip and it yields the same way. A popover's content portaled from inside the wrapped element keeps its own hints.
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>API reference#
Tooltip
A hint on hover and keyboard focus, built on Tippy.js in headless mode (the documented exception to Radix first). It renders no wrapper: the bubble is a role="tooltip" element mounted on the body (or the full-screen element). Every part accepts className, merged last; here it lands on the bubble.
| Prop | Type | Default | Description |
|---|---|---|---|
| content* | ReactNode | - | The bubble's contents: a label, a label plus a glyph, or the graph parts below. |
| children | ReactElement | - | The trigger: a single element that forwards its ref to a DOM node. Optional only with anchor. |
| variant | "text" | "graph" | "text" | text is the inverted one-line chip; graph is the left-aligned popover card for multi-line data. Ignored inside a TooltipGroup. |
| placement | Placement | "top" | Tippy placement: top, right, bottom, left, each with -start or -end, or auto. |
| delay | number | [number, number] | [150, 0] | Hover intent in ms, [open, close] or one number for both. Ignored inside a group. |
| offset | [number, number] | [0, 6] | [skidding, distance] from the trigger in px. Ignored inside a group. |
| interactive | boolean | false | Keeps the bubble open while the pointer is over it, for a single link. Ignored inside a group. |
| trigger | string | "mouseenter focus" | Space-separated events that open it. Focus opens it only when it is keyboard focus; pass "mouseenter" to drop focus hints. |
| hideOnClick | boolean | true | Whether clicking the trigger hides it. Pass false for a toggle whose label changes on click. |
| open | boolean | - | Controlled visibility. Setting it switches the trigger events off; with anchor it defaults to true. |
| anchor | { x: number; y: number; width?: number; height?: number } | - | Hangs the bubble on a point or box in viewport coordinates instead of children (a map pin, a chart point). Moving it re-positions the open bubble. |
| disabled | boolean | false | Keeps the tooltip from showing. Ignored inside a group. |
| Data attribute | Description |
|---|---|
| data-state | visible or hidden on the bubble; drives the enter and exit transition. |
| data-placement | The resolved placement on the bubble; sets the transform origin. |
| data-variant | text or graph on the bubble, so nested parts (a Kbd) can adapt. |
| CSS variable | Description |
|---|---|
| --surface | Set on the bubble to its own ground (--foreground for text, --popover for graph) so nested controls blend with it. |
TooltipGroup
Shares one bubble across the Tooltips inside it, so it glides from trigger to trigger (a Tippy singleton). Each child keeps its own content, placement and trigger; the rest is set here. Renders no element.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "text" | "graph" | "text" | Shape of the shared bubble. |
| delay | number | [number, number] | [150, 200] | Hover intent shared by the group. The 200ms close is the grace period that lets the pointer reach the next trigger. |
| offset | [number, number] | [0, 6] | [skidding, distance] from each trigger in px. |
| disabled | boolean | false | Keeps the shared bubble from showing. |
| className | string | - | Classes merged onto the shared bubble. |
TooltipHeader
The header row of a graph bubble: a TooltipHeaderText column plus a trailing TooltipValue. Forwards all native <div> props.
TooltipHeaderText
The stacked title and description column inside TooltipHeader. Forwards all native <div> props.
TooltipTitle
The bold title line. Forwards all native <div> props.
TooltipDescription
The muted line under the title. Forwards all native <div> props.
TooltipValue
A tonal value pill (a time, count or amount) in tabular-nums. Forwards all native <span> props.
TooltipSeparator
A hairline role="separator" between the header and the sections. Forwards all native <div> props.
TooltipSection
A group of TooltipStat rows banded by a colored left rail. Forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "brand" | "info" | "success" | "warning" | "destructive" | "neutral" | "neutral" | The rail color, a token role so it tracks the theme. |
TooltipStat
One breakdown row: a label, an optional percent and a TooltipValue pill. Forwards all native <div> props except children.
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | ReactNode | - | The row label, truncated to one line. |
| value* | ReactNode | - | The value shown in the trailing pill. |
| percent | ReactNode | - | A muted share shown just before the value. |
| indent | boolean | false | Nests the row one step in, for a sub-line under a total. |