Starter Docs
UI

Tooltip

A short label on hover and keyboard focus, on the native Popover API and placed by CSS anchor positioning, in shadcn's parts. Every animation is CSS.

Make this page public

Installation

Add the registry to components.json, once

components.json
{
  "registries": {
    "@rabtx": "http://localhost:3002/r/{name}.json"
  }
}

Add the component

npx shadcn@latest add @rabtx/tooltip

Or, without the namespace, by URL:

npx shadcn@latest add http://localhost:3002/r/tooltip.json

The files land in your components/ui and are styled by your own theme. Add --overwrite to replace a shadcn component with the same name.

Usage

Installed from the registry, import from @/components/ui/tooltip; inside this monorepo, from @rabtx/ui/tooltip.

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@rabtx/ui/tooltip";

export function Publish() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <Button variant="secondary">Publish</Button>
        </TooltipTrigger>
        <TooltipContent>Make this page public</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

The parts are shadcn's, with the same data-slot names. TooltipContent is a popover="hint" element with role="tooltip": it opens in the top layer above everything (dialogs included), it is not clipped by any overflow, and opening it never closes a menu or another popover that is already open. It stays in the page while closed, so the trigger's aria-describedby always points at it.

It opens when the pointer rests on the trigger for delayDuration (120ms), at once on keyboard focus, and with a tap on a touch screen (a finger has no hover; a second tap or a tap elsewhere hides it). It closes when the pointer leaves both the trigger and the label (you can move onto the label to read or select it), on blur, on Escape, on a press, and when the window loses focus.

TooltipProvider is optional. Put one around a group of tooltips (a toolbar, or the whole app) to share the timing: once one label has shown, the next one opens with no delay until skipDelayDuration has passed since the last one closed. Only one tooltip shows at a time.

Sides

side is the preferred side. When there is no room there, CSS flips it to the opposite side (position-try-fallbacks), and the arrow and the grow origin follow. arrow draws a small arrow that points at the center of the trigger, even when the label is shifted along the edge to stay 8px inside the viewport.

On the topOn the leftOn the rightOn the bottom

Alignment

align lines the label up with the start, the center (default) or the end of the trigger along that side. sideOffset sets the gap in px (6 by default, plus the arrow).

Aligned to the startAligned to the centerAligned to the end

Toolbar and shortcuts

For an icon-only button whose only label is the tooltip, <Tooltip asLabel> makes the tooltip the trigger's accessible name (aria-labelledby) instead of its description, so the button is named even while the tooltip is closed. TooltipKbd draws a shortcut as key caps; it is hidden from assistive technology, so put the shortcut on the trigger as aria-keyshortcuts. Hover one button, then slide along: the next labels open at once.

Controlled

open and onOpenChange control it. onOpenChange is called for every change: hover, focus, Escape, a press elsewhere.

Last synced 2 minutes ago

In text

The trigger can be any focusable element; here it is a button styled as a dotted term, inside a paragraph (the label is a span, so it is valid inside a p). Long labels wrap at 20rem.

Invoices are due on Payment is due 30 days after the invoice date. Late invoices pick up a 1.5% fee each month until they are paid, so send reminders a week before. terms unless the contract says otherwise.

API

TooltipProvider (optional) shares the timing:

Prop

Type

Tooltip holds one tooltip's state and renders nothing itself:

Prop

Type

TooltipTrigger renders a button, or with asChild passes its props to the one child (our Button, a link). TooltipContent takes everything a span takes, plus:

Prop

Type

TooltipKbd is a kbd key cap. Outside React, tooltipClass() and tooltipParts from @rabtx/ui/tooltip/styles give the class names, and Interest Invokers show it with no script at all:

<button interestfor="save-tip" style="anchor-name: --save">Save</button>
<span id="save-tip" popover="hint" role="tooltip" class="rx-tooltip"
  data-side="top" data-align="center" style="position-anchor: --save">Save the draft</span>

TooltipTrigger renders that same interestfor until the page hydrates, so a tooltip already shows before React is running where the browser supports it.

Theming

The Tooltip reads only the theme tokens.

PartTokens
Labelfill --popover, text --popover-foreground, Flat edge --border
Floating depth--surface-ring, --surface-edge-top, --surface-edge-bottom, --surface-sheen, --surface-shade
Key cap--muted, --muted-foreground, --border, --radius-badge
Corners--radius-control
Motion--spring-unfold, --motion-ease

There is no cast shadow: the raised edge and its hairline ring separate the label from the page, and the arrow continues the same edge.

How it works

PiecePlatform feature
Layeringpopover="hint" (an unknown value falls back to manual); shown with showPopover()
Placementthe trigger's anchor-name, the label's position-anchor and a position-area per side and alignment
Flippingposition-try-fallbacks: flip-block, flip-inline, …; an inline margin keeps it 8px inside the viewport
Out of viewposition-visibility: anchors-visible hides it while the trigger is scrolled away
Enter@starting-style (opacity 0, scale 0.94) and the unfold spring, from the trigger's side
Exit100ms eased; display and overlay transition with allow-discrete
Before hydrationInterest Invokers (interestfor) with interest-delay

JavaScript keeps the timing (the delay, the skip delay, the grace while the pointer crosses onto the label), calls showPopover() and hidePopover(), and reads which side CSS chose once the label is shown, so it can set data-placed and the arrow's offset. Reduced motion keeps a 100ms fade; data-motion="off" shows and hides it at once.

Browser support

  • Chromium 142+: everything, including hint, anchor positioning with fallbacks and Interest Invokers before hydration.
  • Safari 26 and Firefox 147+: anchor positioning and the enter animation; without hint the label is a manual popover and the component handles Escape and presses elsewhere itself.
  • Older engines: without anchor positioning, the component measures the trigger and places the label itself, with the same flip and viewport margin.

Accessibility

  • The label has role="tooltip" and the trigger points aria-describedby at it (or, with asLabel, aria-labelledby, and the label is hidden from the tree so it is not read twice).
  • Keyboard focus shows it at once and blur hides it; a focus that comes from a mouse press does not (:focus-visible).
  • Escape hides it without closing a dialog around it. The pointer can move onto the label without it closing (WCAG 1.4.13: dismissible, hoverable, persistent).
  • A tooltip only adds to a control. Do not put links or buttons in it, and do not hide anything a touch user needs only in a tooltip.
  • A disabled button gets no pointer or focus events, so it cannot show a tooltip: use aria-disabled instead, or wrap it.

On this page