A short label shown on hover or keyboard focus, for icon-only buttons, abbreviations and extra context. Tuned delays, a shared warm-up between tooltips, dark and light variants, and an arrow that follows the placement.
TooltipTrigger wraps a focusable trigger (a Button, Link, ToggleButton or any element inside React Aria's Focusable) and the Tooltip that describes it.
Tooltips supplement, they don't replace labels
The tooltip is linked to its trigger with aria-describedby, so it's read as
a description after the trigger's name. Icon-only buttons still need an
aria-label. Touch users never see tooltips, so don't put essential
information or interactive content in them; use a
Popover instead.
placement accepts top (default), bottom, left, right, start, end and combinations like top start. The tooltip flips to the opposite side when there isn't room, and the arrow rotates with it.
import { Button } from "@/components/ui/button";import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";const placements = ["top", "right", "bottom", "left"] as const;export default function TooltipPlacement() { return ( <div className="flex flex-wrap gap-2"> {placements.map((placement) => ( <TooltipTrigger key={placement}> <Button variant="outline" className="capitalize"> {placement} </Button> <Tooltip placement={placement}>Tooltip on {placement}</Tooltip> </TooltipTrigger> ))} </div> );}
variant="light" renders a bordered card that matches popovers. Use it for two-line content, like a metric name and its definition. Tooltips are capped at max-w-xs; pass a max-w-* class for a narrower measure.
import { InfoIcon } from "lucide-react";import { Button } from "@/components/ui/button";import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";export default function TooltipLight() { return ( <TooltipTrigger> <Button variant="ghost" size="icon" aria-label="What is MTTR?"> <InfoIcon /> </Button> <Tooltip variant="light" className="max-w-56"> <p className="font-medium">Mean time to resolve</p> <p className="mt-0.5 text-muted-foreground"> Average time from an alert firing to the incident being closed. </p> </Tooltip> </TooltipTrigger> );}
delay and closeDelay on TooltipTrigger set how long hover waits before opening and closing. Once one tooltip has opened, moving to a nearby trigger opens the next one immediately. Keyboard focus always opens without delay.
import { Button } from "@/components/ui/button";import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";export default function TooltipDelay() { return ( <div className="flex flex-wrap items-center gap-2"> <TooltipTrigger delay={0}> <Button variant="outline">Instant</Button> <Tooltip>Opens immediately</Tooltip> </TooltipTrigger> <TooltipTrigger> <Button variant="outline">Default</Button> <Tooltip>Opens after 400ms</Tooltip> </TooltipTrigger> <TooltipTrigger delay={1200} closeDelay={600}> <Button variant="outline">Slow</Button> <Tooltip>Opens after 1.2s, lingers for 600ms</Tooltip> </TooltipTrigger> </div> );}
isOpen and onOpenChange on TooltipTrigger let you open the tooltip from code, like confirming a copy action. shouldCloseOnPress={false} keeps it open while the trigger is pressed.
isDisabled on TooltipTrigger turns the tooltip off without touching the trigger. Here tooltips only appear while the navigation is collapsed to icons. Disabled buttons never show tooltips, since they don't receive hover or focus.
Wrap non-interactive elements in React Aria's Focusable so they can own a tooltip. The child needs tabIndex={0} and an ARIA role (button, img, link and similar), and must forward its ref.
Median latency (p50) dropped to 182 ms this week −12%
import { Focusable } from "react-aria-components";import { Badge } from "@/components/ui/badge";import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";const focusRing = "cursor-help rounded-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25";export default function TooltipCustomTrigger() { return ( <p className="max-w-sm text-sm leading-relaxed"> Median latency ( <TooltipTrigger> <Focusable> {/* biome-ignore lint/a11y/useSemanticElements: inline term, not a button */} <span role="button" tabIndex={0} className={`${focusRing} underline decoration-dotted underline-offset-4`} > p50 </span> </Focusable> <Tooltip>50th percentile: half of all requests were faster</Tooltip> </TooltipTrigger> ) dropped to 182 ms this week{" "} <TooltipTrigger> <Focusable> <span role="img" aria-label="Down 12%" // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable so keyboard users get the tooltip tabIndex={0} className={focusRing} > <Badge size="sm" color="success"> −12% </Badge> </span> </Focusable> <Tooltip>Compared with the previous 7 days</Tooltip> </TooltipTrigger> </p> );}
The tooltip has role="tooltip" and is linked to its trigger with aria-describedby, so screen readers read it after the trigger's name.
It opens on hover after delay, and immediately when the trigger receives keyboard focus. It doesn't open for touch.
It closes on Esc, when the pointer leaves (after closeDelay), when focus moves away, and when the trigger is pressed (unless shouldCloseOnPress={false}).
Keep tooltip content short and non-interactive. Anything focusable inside a tooltip can't be reached.
The trigger must be focusable. Wrap plain elements in Focusable with a tabIndex and a role.
className on Tooltip styles the bubble: it's w-fit max-w-xs rounded-md px-2.5 py-1.5 text-xs with text-balance. Pass max-w-* or padding classes to change it, or flex items-center gap-2 to lay out rich content.
The default variant uses bg-foreground text-background, so it inverts with the theme; light uses bg-popover with a border.
The arrow's fill follows the variant. When overriding the background, hide the arrow or restyle it via [&_svg]:fill-….
--trigger-anchor-point is set on the tooltip, for a custom transform-origin.