Tooltip
A short Graphite Ink label that names an icon or explains a control, after the app's 400ms delay.
INV-20931
Northwind Freight, $18,240.00, due Oct 21
import { Button } from "@oration/canon/components/button";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ChevronDownIcon, ChevronUpIcon, CopyIcon, DownloadIcon, MoreHorizontalIcon, PrinterIcon,} from "lucide-react";export function Hero() { const actions = [ { label: "Previous invoice", icon: ChevronUpIcon, keys: ["K"] }, { label: "Next invoice", icon: ChevronDownIcon, keys: ["J"] }, { label: "Download PDF", icon: DownloadIcon, keys: [] }, { label: "Print", icon: PrinterIcon, keys: ["⌘", "P"] }, { label: "Copy link", icon: CopyIcon, keys: [] }, { label: "More actions", icon: MoreHorizontalIcon, keys: [] }, ]; return ( <div className="flex w-full max-w-xl items-center gap-3 rounded-xl bg-card px-4 py-3 text-left shadow-border"> <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium">INV-20931</p> <p className="truncate text-xs text-muted-foreground"> Northwind Freight, $18,240.00, due Oct 21 </p> </div> <div className="flex items-center gap-0.5"> {actions.map((action) => ( <Tooltip key={action.label}> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={action.label} onClick={() => toast.add({ title: action.label }) } /> } > <action.icon aria-hidden="true" /> </TooltipTrigger> <TooltipContent> {action.label} {action.keys.length ? ( <KbdGroup> {action.keys.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </KbdGroup> ) : null} </TooltipContent> </Tooltip> ))} </div> </div> );}Usage#
A tooltip is a short Graphite Ink label that names an icon button or explains a control when you hover or focus it. It waits 400ms before the first one appears, then opens instantly as you move along a toolbar, so scanning a row of icons is quick. Keyboard shortcuts ride along as keys after the label. What people get wrong is putting information in it that someone needs: tooltips never open on touch, disappear on the next move, and aren't the accessible name. If it has to be read, it belongs on the page or in an info tip.
When to use
- On every icon-only button, repeating its
aria-label: Download remittance, Copy link. - To show a control's keyboard shortcut: Next invoice with J.
- To explain why a control is unavailable, on a trigger that stays focusable.
- To show the full text of a truncated label in a dense row.
When not to use
- For a definition or explanation someone might need to read twice, or with a link or video. Use Info tip
- For anything interactive, such as a button or a form field. Use Popover
- For a preview of a record behind a link. Use Hover card
- For errors or validation. Put the message beside the field. Use Field
- On a button whose visible label already says the same thing. Use Button
Icon-only buttons carry a name
aria-label that names the action and a tooltip with the same words. The tooltip is for sighted pointer and keyboard users; the label is for everyone.The Machine Mono Rule
Anatomy#
- Trigger. The control the tooltip describes, usually an icon button rendered through
render. - Container. Graphite Ink fill, 8px corners, 6px by 12px padding, up to 20rem wide, 4px from the trigger.
- Label. 12px Caption text in the plane color, one short phrase.
- Shortcut. Optional Kbd chips, tinted to 20% of the plane color. The container's right padding tightens to 6px around them.
- Arrow. A 10px rotated square that points at the trigger.
Examples#
Basic
An icon button with its tooltip. The aria-label and the tooltip say the same words. Hover for 400ms, or tab to it and it opens at once.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { DownloadIcon } from "lucide-react";export function Basic() { return ( <Tooltip> <TooltipTrigger render={ <Button type="button" variant="outline" size="icon" aria-label="Download remittance" onClick={() => toast.add({ title: "Remittance downloaded", description: "RMT-04188 for Halcyon, PDF.", }) } /> } > <DownloadIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Download remittance</TooltipContent> </Tooltip> );}Adjacent tooltips
The first tooltip waits 400ms. Slide down the collapsed rail and each next one opens instantly with no animation, because the provider groups them until 400ms after the last one closes. Rail tooltips open to the right so they don't cover the neighbours.
import { Button } from "@oration/canon/components/button";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { BanknoteIcon, Building2Icon, ChartColumnIcon, HomeIcon, InboxIcon,} from "lucide-react";import * as React from "react";export function Rail() { const items = [ { label: "Home", icon: HomeIcon }, { label: "Inbox", icon: InboxIcon }, { label: "Suppliers", icon: Building2Icon }, { label: "Payment runs", icon: BanknoteIcon }, { label: "Reports", icon: ChartColumnIcon }, ]; const [active, setActive] = React.useState("Suppliers"); return ( <nav aria-label="Workspace" className="flex flex-col gap-1 rounded-xl bg-sidebar p-1.5 shadow-border" > {items.map((item) => ( <Tooltip key={item.label}> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon" aria-label={item.label} aria-current={ item.label === active ? "page" : undefined } className={cn( "text-sidebar-foreground hover:bg-sidebar-accent", item.label === active && "bg-sidebar-accent text-foreground", )} onClick={() => setActive(item.label)} /> } > <item.icon aria-hidden="true" /> </TooltipTrigger> <TooltipContent side="right">{item.label}</TooltipContent> </Tooltip> ))} </nav> );}With a keyboard shortcut
Put Kbd chips after the label, grouped with KbdGroup for chords. Inside a tooltip they tint to the plane color at 20% and the right padding tightens to 6px.
import { Button } from "@oration/canon/components/button";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { SendIcon } from "lucide-react";export function WithShortcut() { return ( <> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="outline" onClick={() => toast.add({ title: "New ticket", description: "Draft saved to your queue.", }) } /> } > New ticket </TooltipTrigger> <TooltipContent> Create a ticket <Kbd>C</Kbd> </TooltipContent> </Tooltip> <Tooltip> <TooltipTrigger render={ <Button type="button" aria-label="Send reply" size="icon" onClick={() => toast.add({ type: "success", title: "Reply sent", description: "Aisha Bello will get it by email.", }) } /> } > <SendIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent> Send reply <KbdGroup> <Kbd>⌘</Kbd> <Kbd>↵</Kbd> </KbdGroup> </TooltipContent> </Tooltip> </> );}Sides
Top is the default. Use side when the tooltip would cover what it describes, such as right in a collapsed sidebar. It flips on its own when there is no room.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon } from "lucide-react";export function Sides() { const moves = [ { label: "Move step up", icon: ArrowUpIcon, side: "top" as const }, { label: "Move step right", icon: ArrowRightIcon, side: "right" as const, }, { label: "Move step down", icon: ArrowDownIcon, side: "bottom" as const, }, { label: "Move step left", icon: ArrowLeftIcon, side: "left" as const }, ]; return ( <> {moves.map((move) => ( <Tooltip key={move.label}> <TooltipTrigger render={ <Button type="button" variant="outline" size="icon-sm" aria-label={move.label} onClick={() => toast.add({ title: move.label })} /> } > <move.icon aria-hidden="true" /> </TooltipTrigger> <TooltipContent side={move.side}> {move.label} </TooltipContent> </Tooltip> ))} </> );}Explaining a disabled control
focusableWhenDisabled keeps the button hoverable and in the tab order, so its tooltip can say what unlocks it.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";export function DisabledReason() { return ( <div className="flex items-center gap-2"> <Button type="button" variant="outline" onClick={() => toast.add({ title: "Draft saved" })} > Save draft </Button> <Tooltip> <TooltipTrigger render={ <Button type="button" disabled focusableWhenDisabled className="aria-disabled:opacity-50" /> } > Approve run </TooltipTrigger> <TooltipContent> Maya Okafor approves runs over $1M </TooltipContent> </Tooltip> </div> );}States#
| State | Treatment |
|---|---|
| Closed | Nothing rendered. |
| Opening | After 400ms of hover it fades in from a 95% scale and slides 8px from the trigger side, over 125ms on the house ease-out. |
| Instant | Opened by keyboard focus, or by moving to an adjacent trigger within 400ms of the last tooltip closing: no delay and no animation (data-instant). |
| Open | Stays while the pointer is on the trigger or the tooltip. |
| Closing | Fades and scales back to 95% over 125ms when the pointer leaves, the trigger is clicked, or Escape is pressed. |
| Disabled | disabled on Tooltip or TooltipTrigger stops it opening. A natively disabled button gets no pointer events, so its tooltip never opens either. |
Behavior#
- The delay comes from
TooltipProvider. The root layout wraps the app in<TooltipProvider delay={400}>; the component's own default is 0, so a nested provider without adelayopens tooltips instantly. - Tooltips under one provider are grouped. Once one has shown, the next opens immediately with no animation, until 400ms (
timeout) after the last one closed. - Keyboard focus opens the tooltip at once, without animation. Escape closes it and focus stays on the trigger.
- Clicking the trigger closes the tooltip (
closeOnClick), so it doesn't cover the menu or result the click opened. - The popup can be hovered without closing, so the pointer can cross onto it. Pass
disableHoverablePopupto turn that off. - It portals to the body, sits above the trigger 4px away by default, and flips to the opposite side when there is no room.
- Tooltips don't open from touch. On a phone, the icon's
aria-labeland the surrounding layout have to carry the meaning. - Under reduced motion the scale and slide drop out and only the fade remains.
Do and don't#
aria-label does.focusableWhenDisabled and let the tooltip say why.Content#
- Sentence case, no trailing period, verb first for actions: Download remittance, Copy link.
- Match the
aria-labelword for word, so what people see and what screen readers say agree. - For unavailable controls, say what unlocks them: Maya Okafor approves runs over $1M.
- Write keys as symbols in their own Kbd: ⌘ ↵, J, Shift C.
Accessibility#
- The tooltip is not the accessible name. Give icon-only triggers an
aria-labelwith the same words. - Mark the trigger's icon
aria-hidden="true". - Tooltips open on keyboard focus as well as hover, so keyboard users see the same hints.
- Content must be short and non-essential: it never shows on touch and can't hold focus.
- A disabled control needs
focusableWhenDisabled(oraria-disabled) so it can still be focused and hovered. - Motion is 125ms and reduces to a fade under reduced motion.
| Keys | Action |
|---|---|
| Tab | Focusing the trigger opens its tooltip immediately. |
| Esc | Closes the tooltip. Focus stays on the trigger. |
Design tokens#
| Token | Used for |
|---|---|
--foreground | The fill and arrow (Graphite Ink; it inverts to a light chip in dark) |
--background | Label text, and the Kbd fill at 20% (10% in dark) |
--radius-md | 8px container corners |
--radius-sm | 6px corners on Kbd chips |
text-xs | 12px Caption label |
--ease-out | The 125ms open and close |
API reference#
TooltipProvider
Shares the delay across tooltips and groups them. Mounted once in the root layout with delay={400}.
Other props spread onto Base UI Tooltip.Provider.
| Prop | Type | Default | Description |
|---|---|---|---|
delay | number | 0 | Milliseconds of hover before a tooltip opens. |
closeDelay | number | No default | Milliseconds before a tooltip closes. |
timeout | number | 400 | Another tooltip opens instantly if the last one closed within this many milliseconds. |
Tooltip
The root of one tooltip.
Other props spread onto Base UI Tooltip.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether the tooltip is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes. |
disabled | boolean | false | Stops the tooltip from opening. |
disableHoverablePopup | boolean | false | Closes the tooltip when the pointer moves onto it. |
trackCursorAxis | "none" | "x" | "y" | "both" | "none" | Follows the cursor along an axis, for charts and bars. |
TooltipTrigger
The element the tooltip is attached to. Renders a button unless render replaces it.
Other props spread onto Base UI Tooltip.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own control, usually <Button size="icon-sm" aria-label="…" />. |
delay | number | 600 | Overrides the provider's delay for this trigger. Leave it unset in the app. |
closeDelay | number | 0 | Milliseconds before closing after the pointer leaves. |
closeOnClick | boolean | true | Closes the tooltip when the trigger is clicked. |
disabled | boolean | false | Stops the tooltip opening from this trigger without disabling the element. |
TooltipContent
The popup, its positioner, portal and arrow.
Other props spread onto Base UI Tooltip.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "inline-start" | "inline-end" | "top" | Which side of the trigger it opens on. |
sideOffset | number | 4 | Distance from the trigger in pixels. |
align | "start" | "center" | "end" | "center" | Alignment along the trigger's edge. |
alignOffset | number | 0 | Offset along the alignment axis. |
className | string | No default | Merged onto the popup after its defaults. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
TooltipProvider defaults delay to 0. The 400ms in DESIGN.md exists only because the root layout passes it; any nested provider without a delay opens tooltips instantly.
TooltipContent still carries data-[state=delayed-open] animation classes from Radix. Base UI never sets that attribute, so they never apply.
Many product tooltips pass className="flex items-center gap-1.5" for a label and Kbd. The popup is already inline-flex items-center gap-1.5, so the class is redundant.
A Button with focusableWhenDisabled gets aria-disabled instead of disabled, and Button only dims on disabled:. A focusable disabled trigger looks enabled unless you add aria-disabled:opacity-50, as the example below does.