Tooltip

A tooltip displays a description of an element on hover or focus.

"use client"

import * as React from "react"
import { cn } from "cn"
import {
  Focusable,
  OverlayArrow,
  Tooltip as TooltipPrimitive,
  TooltipTrigger as TooltipTriggerPrimitive,
} from "@animate-ui/react/primitives/react-aria"
import { Button } from "@/components/ui/button"

function TooltipTrigger({
  delay = 0,
  children,
  ...props
}: React.ComponentProps<typeof TooltipTriggerPrimitive>) {
  const [trigger, tooltip] = React.Children.toArray(children)

  return (
    <TooltipTriggerPrimitive
      data-slot="tooltip-trigger"
      delay={delay}
      {...props}
    >
      <Focusable>
        {trigger as React.ComponentProps<typeof Focusable>["children"]}
      </Focusable>
      {tooltip}
    </TooltipTriggerPrimitive>
  )
}

function Tooltip({
  className,
  placement = "top",
  offset = 4,
  crossOffset = 0,
  children,
  ...props
}: Omit<
  React.ComponentProps<typeof TooltipPrimitive>,
  "children" | "className"
> & {
  className?: string
  children?: React.ReactNode
}) {
  return (
    <TooltipPrimitive
      data-slot="tooltip-content"
      placement={placement}
      offset={offset}
      crossOffset={crossOffset}
      className={cn(
        "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm z-50 w-fit max-w-xs origin-(--trigger-anchor-point) bg-foreground text-background",
        className
      )}
      {...props}
    >
      {children}
      <OverlayArrow
        className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] z-50 bg-foreground fill-foreground"
        style={({ placement, defaultStyle }) => ({
          ...defaultStyle,
          rotate: "0deg",
          translate: "0 0",
          transform:
            placement === "bottom"
              ? "translate(-50%, calc(50% + 2px)) rotate(45deg)"
              : placement === "top"
                ? "translate(-50%, calc(-50% - 2px)) rotate(45deg)"
                : placement === "left"
                  ? "translate(calc(-50% - 2px), -50%) rotate(45deg)"
                  : "translate(calc(50% + 2px), -50%) rotate(45deg)",
        })}
      />
    </TooltipPrimitive>
  )
}

export { Tooltip, TooltipTrigger }

function toPlacement(
  side: "top" | "right" | "bottom" | "left",
  align: "start" | "center" | "end"
) {
  if (align === "center") return side
  if (side === "top" || side === "bottom") return `${side} ${align}` as const
  return `${side} ${align === "start" ? "top" : "bottom"}` as const
}

export default function TooltipDemo({
  side = "top",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
}: {
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
  align?: "start" | "center" | "end"
  alignOffset?: number
}) {
  return (
    <TooltipTrigger>
      <Button variant="outline">Hover</Button>
      <Tooltip
        placement={toPlacement(side, align)}
        offset={sideOffset}
        crossOffset={alignOffset}
      >
        <p>Add to library</p>
      </Tooltip>
    </TooltipTrigger>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add tooltip
pnpm dlx shadcn@latest add tooltip
yarn dlx shadcn@latest add tooltip
bun x shadcn@latest add tooltip

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate tooltip
pnpm dlx @animate-ui/cli@latest animate tooltip
yarn dlx @animate-ui/cli@latest animate tooltip
bun x @animate-ui/cli@latest animate tooltip

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with an animated Tooltip. Everything else stays the same.

import {
  Button,
  OverlayArrow,
  Tooltip,
  TooltipTrigger,
} from '@animate-ui/react/primitives/react-aria';

export function BoldButton() {
  return (
    <TooltipTrigger delay={0}>
      <Button aria-label="Bold">B</Button>
      <Tooltip placement="top" offset={4}>
        <OverlayArrow>
          <svg width={8} height={8} viewBox="0 0 8 8">
            <path d="M0 0 L4 4 L8 0" />
          </svg>
        </OverlayArrow>
        Bold
      </Tooltip>
    </TooltipTrigger>
  );
}

Animation

React Aria has no Animate UI v1 counterpart: the Tooltip moves like v1's Radix Tooltip.

  • Tooltip grows from 0.7 and fades in, from the trigger (--trigger-anchor-point, or the side facing the trigger before React Aria measures it), on a spring (stiffness 400, damping 29) that overshoots a little before it settles.
  • It closes the same way: it shrinks back to 0.7 on the same spring while it fades out, then unmounts.
  • During React Aria's warmup, a tooltip replaced by its neighbour swaps with it at once, both ways, as React Aria intends (shouldSkipAnimation). A cold tooltip plays its entrance.
  • React Aria keeps a closing tooltip mounted only if animations are running when it closes: Animate UI tracks the same open state and passes isExiting while the exit plays.
  • React Aria only opens it on hover for pointer input, and on keyboard focus.
  • OverlayArrow scales with the tooltip; it needs nothing more.
  • Open from the start (defaultOpen), it plays its entrance on page load, like v1.
  • Under reduced motion, it only fades (no scale).

API Reference

Tooltip takes exactly the props of React Aria's Tooltip, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 400, damping: 29 }
exitTransition?
Transitiontransition

TooltipTrigger and OverlayArrow take React Aria props only. They are not animated.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page