Skip to content

Tooltip

A short Graphite Ink label that names an icon or explains a control, after the app's 400ms delay.

Status
Stable
Category
Overlays
Adoption
Not used yet
import { Tooltip } from "@oration/canon/components/tooltip";
packages/canon/src/components/tooltip.tsx

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

Every icon-only button has an 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

Shortcut keys in a tooltip are Kbd chips in Geist Sans, never mono: ⌘ and ↵, not a code span.

Anatomy#

Next invoiceJ
  1. Trigger. The control the tooltip describes, usually an icon button rendered through render.
  2. Container. Graphite Ink fill, 8px corners, 6px by 12px padding, up to 20rem wide, 4px from the trigger.
  3. Label. 12px Caption text in the plane color, one short phrase.
  4. Shortcut. Optional Kbd chips, tinted to 20% of the plane color. The container's right padding tightens to 6px around them.
  5. 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#

States
StateTreatment
ClosedNothing rendered.
OpeningAfter 400ms of hover it fades in from a 95% scale and slides 8px from the trigger side, over 125ms on the house ease-out.
InstantOpened by keyboard focus, or by moving to an adjacent trigger within 400ms of the last tooltip closing: no delay and no animation (data-instant).
OpenStays while the pointer is on the trigger or the tooltip.
ClosingFades and scales back to 95% over 125ms when the pointer leaves, the trigger is clicked, or Escape is pressed.
Disableddisabled 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 a delay opens 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 disableHoverablePopup to 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-label and 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#

Download remittance
Do. Name the action in two to four words, exactly as the button's aria-label does.
Downloads the remittance advice as a PDF. Remittances list every invoice a payment covers, and suppliers use them to reconcile. You can also email it from the payment run.
Don't. Write a paragraph in a tooltip. Nobody can reread it, and it disappears on touch. Use an info tip.
Next invoiceJ
Do. Show the shortcut as Kbd chips after the label.
Next invoice (press J)
Don't. Write the shortcut in brackets as plain text. It reads as part of the name and doesn't look like keys.
Do. Keep a disabled control focusable with focusableWhenDisabled and let the tooltip say why.
Don't. Put a tooltip on a natively disabled button. It can't be hovered or focused, so the reason is never shown.

Content#

  • Sentence case, no trailing period, verb first for actions: Download remittance, Copy link.
  • Match the aria-label word 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-label with 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 (or aria-disabled) so it can still be focused and hovered.
  • Motion is 125ms and reduces to a fade under reduced motion.
Keyboard interactions
KeysAction
TabFocusing the trigger opens its tooltip immediately.
EscCloses the tooltip. Focus stays on the trigger.

Design tokens#

Design tokens
TokenUsed for
--foregroundThe fill and arrow (Graphite Ink; it inverts to a light chip in dark)
--backgroundLabel text, and the Kbd fill at 20% (10% in dark)
--radius-md8px container corners
--radius-sm6px corners on Kbd chips
text-xs12px Caption label
--ease-outThe 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.

Props of TooltipProvider
PropTypeDefaultDescription
delaynumber0Milliseconds of hover before a tooltip opens.
closeDelaynumberNo defaultMilliseconds before a tooltip closes.
timeoutnumber400Another 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.

Props of Tooltip
PropTypeDefaultDescription
openbooleanNo defaultControls whether the tooltip is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes.
disabledbooleanfalseStops the tooltip from opening.
disableHoverablePopupbooleanfalseCloses 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>).

Props of TooltipTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender your own control, usually <Button size="icon-sm" aria-label="…" />.
delaynumber600Overrides the provider's delay for this trigger. Leave it unset in the app.
closeDelaynumber0Milliseconds before closing after the pointer leaves.
closeOnClickbooleantrueCloses the tooltip when the trigger is clicked.
disabledbooleanfalseStops 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.

Props of TooltipContent
PropTypeDefaultDescription
side"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""top"Which side of the trigger it opens on.
sideOffsetnumber4Distance from the trigger in pixels.
align"start" | "center" | "end""center"Alignment along the trigger's edge.
alignOffsetnumber0Offset along the alignment axis.
classNamestringNo defaultMerged 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.