Tooltip

A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.

import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

export default function TooltipDemo() {
  return (
    <Tooltip>
      <TooltipTrigger render={<Button variant="outline" />}>Hover</TooltipTrigger>
      <TooltipContent>Add to library</TooltipContent>
    </Tooltip>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts provides the positioner's layer, and state-styles.ts is how the popup and its arrow read their Base UI state.

Copy and paste the following code into your project.

components/ui/tooltip.tsx
"use client"

import type { CSSProperties } from "react"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"

import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import { color, duration, ease, layer, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  hidden: { opacity: 0 },
  content: {
    zIndex: layer.popup,
    display: "inline-flex",
    alignItems: "center",
    gap: space.s1_5,
    width: "fit-content",
    maxWidth: "20rem",
    borderRadius: radius.md,
    paddingInline: space.s3,
    paddingBlock: space.s1_5,
    backgroundColor: color.foreground,
    color: color.background,
    boxShadow: `inset 0 1px 0 ${color.buttonPrimaryHighlight}, 0 4px 12px -2px ${color.popupShadowFar}`,
    fontSize: text.xs,
    lineHeight: leading.xs,
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.fade,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  instant: { transitionDuration: "0s" },
  arrow: {
    zIndex: layer.popup,
    width: space.s2_5,
    height: space.s2_5,
    borderRadius: 2,
    backgroundColor: color.foreground,
  },
})

const arrowSides = stylex.create({
  top: {
    bottom: `calc(-1 * ${space.s2_5})`,
    transform: "translateY(calc(-50% - 2px)) rotate(45deg)",
  },
  bottom: { top: space.s1, transform: "translateY(calc(-50% - 2px)) rotate(45deg)" },
  left: { right: `calc(-1 * ${space.s1})`, transform: "rotate(45deg)" },
  right: { left: `calc(-1 * ${space.s1})`, transform: "rotate(45deg)" },
  "inline-start": { right: `calc(-1 * ${space.s1})`, transform: "rotate(45deg)" },
  "inline-end": { left: `calc(-1 * ${space.s1})`, transform: "rotate(45deg)" },
})

const slide = stylex.create({
  top: { transform: `scale(0.95) translateY(${space.s2})` },
  bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
  left: { transform: `scale(0.95) translateX(${space.s2})` },
  right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  "inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
  "inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }

type StateOverrides = Overrides & { style?: CSSProperties }

function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
  return <TooltipPrimitive.Provider delay={delay} {...props} />
}

function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {
  return <TooltipPrimitive.Root {...props} />
}

function TooltipTrigger<Payload>(props: TooltipPrimitive.Trigger.Props<Payload>) {
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}

type TooltipContentProps = Omit<TooltipPrimitive.Popup.Props, "className" | "style"> &
  Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
  StateOverrides

function TooltipContent({
  xstyle,
  style,
  className,
  side = "top",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  children,
  ...props
}: TooltipContentProps) {
  return (
    <TooltipPrimitive.Portal>
      <TooltipPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        {...stylex.props(states.positioner)}
      >
        <TooltipPrimitive.Popup
          data-slot="tooltip-content"
          {...props}
          {...stateStyles(
            (state) => [
              styles.content,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                slide[state.side],
              state.instant && styles.instant,
              xstyle,
            ],
            {
              className,
              style,
            },
          )}
        >
          {children}
          <TooltipPrimitive.Arrow
            data-slot="tooltip-arrow"
            {...stateStyles((state) => [styles.arrow, arrowSides[state.side]])}
          />
        </TooltipPrimitive.Popup>
      </TooltipPrimitive.Positioner>
    </TooltipPrimitive.Portal>
  )
}

export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

Update the import paths to match your project setup.

Wrap your app in the TooltipProvider.

main.tsx
import "./styles/theme.css"
import "./styles/interop.css"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { TooltipProvider } from "@/components/ui/tooltip"
import App from "./App"

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <TooltipProvider>
      <App />
    </TooltipProvider>
  </StrictMode>,
)

Usage

import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
<Tooltip>
  <TooltipTrigger>Hover</TooltipTrigger>
  <TooltipContent>Add to library</TooltipContent>
</Tooltip>

Composition

TooltipProvider           once, at the app root
└── Tooltip
    ├── TooltipTrigger
    └── TooltipContent    portal + positioner + popup + arrow

TooltipTrigger renders a <button>. Pass render to use a different element as the trigger, such as a Button variant.

Every tooltip inside the provider shares its delay. While one is open, moving to a neighbour opens the next one with no delay and no transition.

A tooltip opens on hover and on keyboard focus, never on press. A touch screen has no hover, so the tooltip never opens there. Put nothing in it that a reader cannot also reach another way, and give an icon-only trigger an aria-label of its own rather than relying on the tooltip's text.

Side

Use side on TooltipContent to pick the edge it opens from. Base UI flips the side when there is no room, and the arrow follows.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { HStack } from "@/components/ui/stack"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

const styles = stylex.create({
  trigger: { textTransform: "capitalize" },
})

const sides = ["left", "top", "bottom", "right"] as const

export default function TooltipSides() {
  return (
    <HStack gap={2} wrap="wrap">
      {sides.map((side) => (
        <Tooltip key={side}>
          <TooltipTrigger render={<Button variant="outline" xstyle={styles.trigger} />}>
            {side}
          </TooltipTrigger>
          <TooltipContent side={side}>Add to library</TooltipContent>
        </Tooltip>
      ))}
    </HStack>
  )
}

Disabled button

A disabled button fires no pointer events, so wrap it in a <span> and make the span the trigger.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

const styles = stylex.create({
  wrapper: { display: "inline-block", width: "fit-content" },
})

export default function TooltipDisabled() {
  return (
    <Tooltip>
      <TooltipTrigger render={<span {...stylex.props(styles.wrapper)} />}>
        <Button variant="outline" disabled>
          Disabled
        </Button>
      </TooltipTrigger>
      <TooltipContent>This feature is currently unavailable</TooltipContent>
    </Tooltip>
  )
}

Keyboard shortcut

Put a Kbd in the content to show the shortcut beside the label.

import { Button } from "@/components/ui/button"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { HStack } from "@/components/ui/stack"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

export default function TooltipKbd() {
  return (
    <Tooltip>
      <TooltipTrigger render={<Button variant="outline" />}>Save</TooltipTrigger>
      <TooltipContent>
        <HStack gap={2} align="center">
          Save changes
          <KbdGroup>
            <Kbd>⌘</Kbd>
            <Kbd>S</Kbd>
          </KbdGroup>
        </HStack>
      </TooltipContent>
    </Tooltip>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Tooltip part of the same name and accepts its props. See the Base UI reference for the full list. TooltipContent also takes xstyle and className, and style for real inline CSS, merged over whatever its Base UI state resolves to.

TooltipProvider

PropTypeDefault
delaynumber0
closeDelaynumber—
timeoutnumber400

timeout is how long after one tooltip closes the next one still opens instantly.

Tooltip

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
disabledbooleanfalse
disableHoverablePopupbooleanfalse
trackCursorAxis"none" | "x" | "y" | "both""none"
handleTooltipHandle—

TooltipTrigger

PropTypeDefault
delaynumber600
closeDelaynumber0
closeOnClickbooleantrue
disabledbooleanfalse
payloadPayload—

A delay on the trigger is only read when the provider sets none.

TooltipContent

PropTypeDefault
side"top" | "bottom" | "left" | "right""top"
sideOffsetnumber4
align"start" | "center" | "end""center"
alignOffsetnumber0
styleCSSProperties—

The popup is as wide as its text and wraps past 20rem. Change the width through xstyle.

Data attributes

AttributeOnEffect
data-slot="tooltip-content"the popupPopup surface
data-slot="tooltip-arrow"the arrowPositioned by Base UI on the cross axis, by the side on ours
data-side, data-alignbothWhich way the popup slides in from, and where the arrow sits
data-instantthe popupBase UI writes it when the transition is skipped
data-starting-style, data-ending-stylethe popupBase UI writes them while the popup animates
data-popup-openthe triggerBase UI writes it while this trigger's tooltip is open