A deferred, non-modal label anchored to a trigger (hover / focus). A flat bubble with a pointer arrow, in two tones — a high-contrast dark bubble (default) or a surface-coloured one (inverted).
Playground
Preview
import{Tooltip,TooltipProvider,TooltipTrigger,TooltipPortal,TooltipContent,TooltipArrow}from"@/components/ui/tooltip";<TooltipProvider><Tooltip><TooltipTriggerasChild>{/* wire a unique anchor-name ↔ position-anchor pair */}<buttonstyle={{ anchorName:"--tip"}}>Hover me</button></TooltipTrigger><TooltipPortal><TooltipContenttone="default"size="md"style={{ positionAnchor:"--tip"}}> Save your changes<TooltipArrow/></TooltipContent></TooltipPortal></Tooltip></TooltipProvider>
import{Tooltip}from"@primitiv-ui/react";<Tooltip.Provider><Tooltip.Root><Tooltip.TriggerasChild>{/* wire a unique anchor-name ↔ position-anchor pair */}<buttonstyle={{ anchorName:"--tip"}}>Hover me</button></Tooltip.Trigger><Tooltip.Portal><Tooltip.Contentstyle={{ positionAnchor:"--tip"}}> Save your changes<Tooltip.Arrow/></Tooltip.Content></Tooltip.Portal></Tooltip.Root></Tooltip.Provider>
Density is set by a data-density ancestor — the Context system, not a Tooltip prop.
Installation
npx primitiv add tooltip
pnpm dlx primitiv add tooltip
yarn dlx primitiv add tooltip
bunx primitiv add tooltip
Import
import{Tooltip}from"@/components/ui/tooltip";
Copied into your project as .primitiv-tooltip — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Wrap the app (or a group) in one Tooltip.Provider to share the open delay, then per tooltip: Tooltip.Root holds the open state, Tooltip.Trigger is the anchor (usually asChild over your own control), and Tooltip.Portal renders Tooltip.Content (with an optional Tooltip.Arrow) in the top layer. Positioning is CSS anchor positioning, which you wire — a unique anchor-name on the trigger, a matching position-anchor on the content.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Tooltip.Provider
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The tooltips (and other markup) that share this Provider's timings.
delayDuration
number
700
headless
How long, in milliseconds, a pointer must rest on a trigger before the
tooltip opens on hover. Focus always opens immediately regardless of this.
Overridable per tooltip via TooltipRootProps.delayDuration.
skipDelayDuration
number
300
headless
The grace window, in milliseconds, after a tooltip in this group closes
during which hovering another trigger in the same Provider opens it
instantly (no delayDuration wait). Rest longer than this and the next
hover pays the full delay again.
Tooltip.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The tooltip's TooltipTriggerProps`Tooltip.Trigger`,
TooltipContentProps`Tooltip.Content` (usually inside a
TooltipPortalProps`Tooltip.Portal`), and optional
TooltipArrowProps`Tooltip.Arrow`.
defaultOpen
boolean
false
headless
Whether the tooltip is open on first render. The component owns the flag
from then on (hover/focus/blur/Escape drive it internally).
Forbidden in controlled mode — use open instead.
delayDuration
number
—
headless
Overrides TooltipProviderProps.delayDuration for this tooltip only
— the hover open delay in milliseconds. Falls back to the Provider's value
when omitted.
disableHoverableContent
boolean
false
headless
When true, removes the pointer-leave grace period: the tooltip closes the
instant the pointer leaves the trigger, so the user cannot move into the
content. Leave false for tooltips whose content is meant to be hovered.
onOpenChange
(open: boolean) => void
—
headless
Called with the new open state after every transition. Optional in
uncontrolled mode; use it to observe (not drive) open/close.
Called with the requested open state whenever the tooltip wants to open
or close. Optional even in controlled mode.
open
boolean
—
headless
Forbidden in uncontrolled mode — use defaultOpen instead.
The current open state. The delay and grace timers still run and call
onOpenChange, but the tooltip only moves when the parent updates this.
Tooltip.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the single child element (a link, icon button, etc.) instead of the
default <button>, merging the trigger's ARIA and event handlers onto it
via the Slot pattern.
Tooltip.Portal
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The content to portal — typically a single
TooltipContentProps`Tooltip.Content`.
container
HTMLElement
document.body
headless
The element to portal the content into.
forceMount
boolean
false
headless
Keep the content mounted while the tooltip is closed (instead of removing
it), so a CSS exit animation on data-state="closed" can play.
Tooltip.Content
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
forceMount
boolean
false
headless
Keep the content mounted while the tooltip is closed (instead of removing
it), so a CSS exit animation on data-state="closed" can play.
onEscapeKeyDown
(event: KeyboardEvent) => void
—
headless
Fires when Escape is pressed while the tooltip is open. Call
event.preventDefault() to keep the tooltip open; otherwise it closes.
onPointerDownOutside
(event: PointerEvent) => void
—
headless
Fires on a pointer-down outside the content. Call event.preventDefault()
to keep the tooltip open; otherwise it closes.
tone
"default" | "inverted"
default
styled
The bubble's colour treatment. default is a high-contrast dark bubble (inverts with the theme); inverted is a surface-coloured bubble for use on dark backgrounds.
size
"sm" | "md" | "lg" | "xl"
md
styled
Bubble size; data-density scales the padding within each size.
Which side of the anchor the bubble sits on, and how it aligns — sets the CSS position-area and points the arrow at the anchor. Wire anchor-name on the trigger and a matching position-anchor on this bubble.
Tooltip.Arrow
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the single child element (e.g. an <svg>) instead of the default
<span> via the Slot pattern.
Styling contract
12 CSS custom properties on .primitiv-tooltip — mode-agnostic. These names are the stable surface; the values are not.
A tooltip is a label, not a menu — it has no keys of its own. What matters is that it opens on focus (immediately, no delay) as well as hover, so a keyboard user gets the same hint, and Escape dismisses it without moving focus.
Key
Behaviour
Tab
Move focus to the trigger — the tooltip opens immediately on focus.
Escape
Dismiss the tooltip while keeping focus on the trigger.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
TooltipContent
className:.primitiv-tooltip
Attribute
Value
When
data-state
open | closed
open / closed
Tooltip.Content
Attribute
Value
When
data-state
open | closed
open / closed
Accessibility
The tooltip opens on focus, not only hover, so a keyboard user gets the same hint as a pointer user — a hover-only tooltip is invisible to anyone not using a mouse.
Tooltip.Content is wired to the trigger by aria-describedby, so the label is announced as a description of the control rather than as loose text elsewhere on the page.
It is non-modal and does not trap focus or steal it — the tooltip describes the focused control, so moving focus away closes it. Escape dismisses it while keeping focus put.
A tooltip is a supplement, never the only source of a control's name: an icon-only button still needs its own aria-label, because the tooltip may never open for touch users.
One Tooltip.Provider shares an open delay across a group, and once one tooltip is open the rest open instantly for a short window — so scanning a toolbar does not mean waiting out the delay on every button.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
On a control
The usual use: an asChild trigger over a real control, with a short label in the content. Hover or focus the button to open it — on hover after a delay, on focus immediately. Note the anchor pair: a unique anchor-name on the trigger and a matching position-anchor on the content is what places the bubble; the component leaves that CSS to you.
placement sets where the content sits relative to the trigger — top/right/bottom/left plus -start/-end alignments (13 in all). The arrow follows. It is a preference: if there is not room on that side, the tooltip flips to the opposite edge automatically. Hover or focus each trigger to see it.
Two tones: default is a surface-coloured bubble that sits quietly against the page, inverted is a high-contrast dark chip for when the tooltip needs to read over busy or image content. Both carry the arrow. Hover or focus a trigger to see the tooltip.