Skip to content
Primitiv home
Framework
Consumption mode

Tooltip

stableSource Figma

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

Density

Preview

Size
Tone
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow } from "@/components/ui/tooltip";
<TooltipProvider>  <Tooltip>    <TooltipTrigger asChild>      {/* wire a unique anchor-name ↔ position-anchor pair */}      <button style={{ anchorName: "--tip" }}>Hover me</button>    </TooltipTrigger>    <TooltipPortal>      <TooltipContent tone="default" size="md" style={{ positionAnchor: "--tip" }}>        Save your changes        <TooltipArrow />      </TooltipContent>    </TooltipPortal>  </Tooltip></TooltipProvider>

Density is set by a data-density ancestor — the Context system, not a Tooltip prop.

Installation

npx 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

<TooltipProvider>  <Tooltip>    <TooltipTrigger />    <TooltipPortal>      <TooltipContent>        <TooltipArrow />      </TooltipContent>    </TooltipPortal>  </Tooltip></TooltipProvider>

Props

Tooltip.Provider

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe tooltips (and other markup) that share this Provider's timings.
delayDurationnumber700headlessHow 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.
skipDelayDurationnumber300headlessThe 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.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe tooltip's TooltipTriggerProps`Tooltip.Trigger`, TooltipContentProps`Tooltip.Content` (usually inside a TooltipPortalProps`Tooltip.Portal`), and optional TooltipArrowProps`Tooltip.Arrow`.
defaultOpenbooleanfalseheadlessWhether 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.
delayDurationnumberheadlessOverrides TooltipProviderProps.delayDuration for this tooltip only — the hover open delay in milliseconds. Falls back to the Provider's value when omitted.
disableHoverableContentbooleanfalseheadlessWhen 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) => voidheadlessCalled 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.
openbooleanheadlessForbidden 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe content to portal — typically a single TooltipContentProps`Tooltip.Content`.
containerHTMLElementdocument.bodyheadlessThe element to portal the content into.
forceMountbooleanfalseheadlessKeep 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.

PropTypeDefaultFromDescription
forceMountbooleanfalseheadlessKeep 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) => voidheadlessFires when Escape is pressed while the tooltip is open. Call event.preventDefault() to keep the tooltip open; otherwise it closes.
onPointerDownOutside(event: PointerEvent) => voidheadlessFires on a pointer-down outside the content. Call event.preventDefault() to keep the tooltip open; otherwise it closes.
tone"default" | "inverted"defaultstyledThe 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"mdstyledBubble size; data-density scales the padding within each size.
placement"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"topstyledWhich 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the single child element (e.g. an <svg>) instead of the default <span> via the Slot pattern.

Styling contract

--primitiv-tooltip-surface--primitiv-tooltip-fg--primitiv-tooltip-radius--primitiv-tooltip-padding-inline--primitiv-tooltip-padding-block--primitiv-tooltip-arrow-size--primitiv-tooltip-offset--primitiv-tooltip-max-inline-size--primitiv-tooltip-font-family--primitiv-tooltip-font-size--primitiv-tooltip-font-weight--primitiv-tooltip-line-height

Keyboard

KeyBehaviour
TabMove focus to the trigger — the tooltip opens immediately on focus.
EscapeDismiss the tooltip while keeping focus on the trigger.

Data attributes

TooltipContent

className: .primitiv-tooltip

AttributeValueWhen
data-stateopen | closedopen / 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

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.

Density
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow } from "@/components/ui/tooltip";
<TooltipProvider>  <Tooltip>    <TooltipTrigger asChild>      <button aria-label="Settings" style={{ anchorName: "--settings" }}></button>    </TooltipTrigger>    <TooltipPortal>      <TooltipContent style={{ positionAnchor: "--settings" }}>        Settings<TooltipArrow />      </TooltipContent>    </Tooltip>  </TooltipPortal></TooltipProvider>

Placement

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.

Density
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow } from "@/components/ui/tooltip";
<TooltipContent placement="right" style={{ positionAnchor: "--tip" }}><TooltipArrow /></TooltipContent>

Tones

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.

Density
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow } from "@/components/ui/tooltip";
<TooltipContent tone="inverted" style={{ positionAnchor: "--tip" }}></TooltipContent>