Tooltip
A popup that displays additional information when hovering over a trigger element.
Installation
$
Usage
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"Examples
Default
A short label for a control whose job is not obvious from its icon. It only appears on hover and focus, so never put anything in it that a touch user needs.
Positioning
Control where the tooltip shows with side (top/right/bottom/left) and align (start/center/end) on TooltipContent; both pass straight through to Base UI's positioner. Pick a cell and the code below updates to match your selection.
Tooltip placement
side="top" align="center"
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| sideTooltipContent | "top" | "right" | "bottom" | "left" | "top" | Preferred edge of the trigger to render against. |
| alignTooltipContent | "start" | "center" | "end" | "center" | Alignment against the trigger along the chosen side. |
| sideOffsetTooltipContent | number | 6 | Distance in pixels between the tooltip and the trigger. |
| collisionAvoidanceTooltipContent | CollisionAvoidance | { fallbackAxisSide: "end" } | Object controlling how the tooltip avoids viewport collisions on the side and alignment axes, e.g. { side: 'none', align: 'none' } to always honor side/align. |
| delayTooltip | number | 200 | Dwell time in ms before the tooltip opens on hover. Mount one TooltipProvider near your app root: its delay applies site-wide, and it forms the skip-delay group, so the next tooltip you hover within 400ms opens instantly. Without a Provider each Tooltip becomes its own group and pays the full delay every time. |
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/registry/lib/utils"
// Module-private, not exported: a "use client" module can't export a
// non-component value (verify-registry rule 6). Tooltip reads this flag to
// tell whether it already sits under a kit TooltipProvider. The Provider
// owns both the delay AND Base UI's skip-delay group: hovering a second
// tooltip within the timeout opens it instantly, but only among tooltips
// sharing one Provider. Wrapping every Tooltip in its own Provider (the old
// behavior) shadowed an app-level Provider's delay and put every tooltip in
// its own group, so a row of icons never got the instant-skip feel. This
// auto-wrap only exists so a bare Tooltip with no Provider above it still
// works; it detects the kit's own TooltipProvider, not a raw
// TooltipPrimitive.Provider a consumer might mount directly, so that one
// case still nests (harmless, same as today's behavior).
const HasTooltipProvider = React.createContext(false)
function TooltipProvider({
// 200, not 300: this is a dwell delay, not an animation, so the 150-250ms
// duration tier does not govern it. It only has to outlast a cursor crossing
// an icon on its way somewhere else. At 300 the first hover reads as a wait.
// Base UI's `timeout` (400ms default) then opens the NEXT tooltip in this
// Provider instantly, which is the half that actually fixes a row of icons.
delay = 200,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<HasTooltipProvider.Provider value={true}>
<TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
</HasTooltipProvider.Provider>
)
}
// @use-when a short label naming what a control does on hover or focus.
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
const hasProvider = React.useContext(HasTooltipProvider)
const root = <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return hasProvider ? root : <TooltipProvider>{root}</TooltipProvider>
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 6,
side,
align,
alignOffset,
collisionAvoidance,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Popup> &
Pick<
React.ComponentProps<typeof TooltipPrimitive.Positioner>,
"side" | "align" | "sideOffset" | "alignOffset" | "collisionAvoidance"
>) {
return (
<TooltipPrimitive.Portal>
{/* z-50 goes on the Positioner, not the Popup: the Popup is
`position: static`, where z-index has no effect. See DropdownMenu. */}
<TooltipPrimitive.Positioner
// positionMethod="fixed": Base UI's Positioner defaults to
// "absolute", which positions the portaled popup in DOCUMENT
// coordinates. On open, Base UI moves focus into the popup before
// Floating UI has computed the transform, so the browser scrolls the
// focused element into view while it is still sitting at the document
// origin, and the whole page jumps to the top. With "fixed" the
// pre-position origin is the viewport, so there is nothing to scroll
// to. It also keeps the popup correct inside a scrolling or
// transformed ancestor.
positionMethod="fixed"
className="z-50"
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionAvoidance={collisionAvoidance}
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
// Inverted, arrow-less hint - consistent with the kit's other overlays
// (none use an arrow). Subtle ease-out enter: fade + small scale + a
// short slide away from the trigger.
// px-3 with py-1.5: 13px text over 6px of vertical padding puts the
// box at ~30px, so it takes the 12px side padding a 32px control
// does. At 8px a one-word hint reads as a square rather than a pill.
// text-balance, and this is one of only three places left in the kit
// that keeps it. The popup is `w-fit`, so the BOX hugs the text:
// balance shrinking the line to its tightest fit shrinks the tooltip
// with it, which turns a long-line-plus-orphan into a tidy
// rectangle. Every left-aligned title in a fixed-width box reads
// `text-pretty` instead, for the reason spelled out in dialog.tsx.
"bg-foreground text-background transition-[opacity,scale,translate] motion-reduce:transition-[opacity] ease-out duration-150 data-[instant]:duration-0 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:scale-95 data-[side=bottom]:data-[starting-style]:-translate-y-1 data-[side=bottom]:data-[ending-style]:-translate-y-1 data-[side=top]:data-[starting-style]:translate-y-1 data-[side=top]:data-[ending-style]:translate-y-1 data-[side=right]:data-[starting-style]:-translate-x-1 data-[side=right]:data-[ending-style]:-translate-x-1 data-[side=left]:data-[starting-style]:translate-x-1 data-[side=left]:data-[ending-style]:translate-x-1 w-fit origin-(--transform-origin) rounded-md px-3 py-1.5 text-xs font-medium text-balance shadow-md",
className
)}
{...props}
>
{children}
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }