A hover and focus triggered label, built on floating-ui, for an icon's meaning, a disabled control's reason, or a truncated value's full text. It degrades to a bare passthrough wrapper when there is nothing to show, so callers wrap unconditionally rather than conditionally rendering it at the call site.
The canon behind it. docs/design-language/02-buttons-and-actions.md §A9 (02-buttons-and-actions.md:43, an interactive thing is a real control, and it has a name) and docs/design-language/03-status-language.md §A6 (03-status-language.md:30, non-trivial status explains itself, every rich badge carries a tip).
When to reach for it#
Wrap a trigger in Tooltip whenever the trigger's meaning is not otherwise visible: an icon-only button, a value that might be empty, a disabled control whose reason needs stating. Because it degrades cleanly with nothing to show, the call site does not need an {condition && <Tooltip>...} guard; pass content={maybeUndefined} and let the component decide.
Several kit components already carry their own internal tooltip and should not be wrapped again:
Timestampalways wraps itself in aTooltip(timestamp/timestamp.tsx:62-74). A caller comment in Settings records the failure mode directly: "Timestamp carries its own tooltip; wrapping it stacks two" (walmart-mvp/frontend/src/pages/Settings.tsx:519).Buttonwraps itself when given atooltipprop (button/button.tsx:172-199).Chipwraps itself when given atooltipprop (chip/chip.tsx:87-105).StatTilewraps itself when given atooltipprop (stat-tile/stat-tile.tsx:136-139).Tabswraps a disabled tab in aTooltipwhen that tab has adisabledReason(tabs/tabs.tsx:160-163).
Pass the tooltip content to the component's own prop instead of wrapping it a second time. ModalTitle's close button is the one internal case that wraps a plain unlabeled icon button rather than exposing a tooltip prop, because that button is kit chrome, not a caller-facing element.
Contract#
children (required) is the trigger, wrapped unconditionally. The wrapper is always the same root element type across every branch (disabled, empty, unmounted, open, closed), because swapping it for a fragment in any branch would tear down and remount whatever children holds (tooltip.tsx:172-175).
content is the tooltip body. A string auto-splits on \n into separate lines; JSX passes through untouched. render is a function form that wins over content when both are given, for content that must be computed lazily.
title and footer are the rich preset, described in its own section below.
color (SemanticColor, default "neutral") recolors the tip surface and its arrow.
markdown parses string content with the markdown-lite subset (bold, italic, code, links, newlines).
disabled suppresses the tooltip entirely; the trigger renders as a plain wrapper with none of floating-ui's positioning or event machinery touched. This is the same degrade path as having nothing to show, just forced rather than inferred from empty content.
placement (floating-ui Placement, default "top") and delay (ms, default 200, or a { open, close } pair) tune position and hover timing. noPortal renders inline instead of through the shared tooltip portal registry; portalId overrides which portal target it uses. containerProps (tooltip.types.ts:35) passes raw DOM attributes to the trigger wrapper, the same escape hatch Card and Dropdown document for theirs.
A tooltip whose trigger sits inside a Modal's <dialog> automatically gets that dialog as its flip and shift boundary (tooltip.tsx:92-96, 105-106), so it cannot render past the modal's edge even though it portals to document.body. This is automatic, not a prop you set.
The rich preset: title, body, footer#
A tooltip has three slots and the standard shape uses two of them. title is a bold heading above the body (tooltip.tsx:162-164), content is the body, footer is muted small print below it (:166-168). Any of the three alone is enough to show the tip. When a title or footer is present the tip wraps at a readable width and takes a little more vertical padding (max-w-80 py-2, :142-160), so a two-line tip reads as heading plus sentence rather than one long line. titleClassName and footerClassName restyle the two extra slots.
Reach for it, rather than a plain content, in these cases:
- An icon-only button. The icon replaced a label, so the tip owes the reader that label first and the help second:
titleis the word the button would have shown,contentis what it does. This is the standard shape for every icon-only action in a header, toolbar, or table row. The accessible name follows the same rule:ButtonandCopyButtonuse a stringtitleas thearia-labelwhen there is no visible label (button/button.tsx:128-146,copy-button/copy-button.tsx:76-80). - A number that needs its breakdown.
titlenames the figure,contentlists the parts,footercarries the caveat or the "as of" (the StatTile breakdown example on the showcase page). - A keyboard shortcut or a source.
footeris the right slot for "⌘K", "from the nightly sync", or "read only while a job runs" under a plain body.
Do not use title as a second body: it is a heading, one short phrase, never a sentence. If there is nothing to put above the body, pass content alone; a rich tip with a title that repeats the body is noise.
Button and CopyButton do not expose title and footer directly; pass them through tooltipProps (Button tooltip="what it does" tooltipProps={{ title: "Label" }}), which spreads onto the internal Tooltip after content (button/button.tsx:173-174, 200-201).
Sanctioned combinations#
| Combination | What it produces | Where used | Why |
|---|---|---|---|
Tooltip content="Close" placement="bottom" around ModalTitle's close button | A labeled icon-only close control | packages/ui/src/modal/modal-elements.tsx:74-80 | Bottom placement points into the dialog, so the tip can never paint past the modal's top edge whatever the boundary math resolves (source comment). |
Button tooltip="..." (Button's own prop, no wrapping Tooltip) | An icon-only or ambiguous button with its meaning stated on hover | button/button.tsx:172-199 | Button already composes Tooltip internally; this is the sanctioned way to attach one, not a separate wrap. |
Tabs item with disabledReason (no wrapping Tooltip) | A disabled tab that explains why on hover | tabs/tabs.tsx:160-163 | Tabs conditionally wraps only the disabled items that carry a reason, so enabled tabs pay no tooltip cost. |
Icon-only Button or CopyButton with tooltip="what it does" tooltipProps={{ title: "Label" }} | A circle button whose tip is the label it replaced, then one line of help | apps/playground/src/app/showcase-title.tsx:55-84 (the Copy prompt, Contract doc, Copy path actions on every showcase page) | The rich preset's standard case: title = the missing label, body = the help; the title doubles as the accessible name. |
Banned combinations#
Do not wrap Timestamp, Button (with its own tooltip prop set), Chip (with tooltip set), or StatTile (with tooltip set) in a second <Tooltip>. Each already renders one internally; stacking a second produces two overlapping tips on hover. The Settings.tsx comment cited above is the one place this is recorded in the codebase, but the rule applies to every self-tooltipping component in the list, not only Timestamp.
Do not conditionally render <Tooltip> around a trigger based on whether content is present. The component already does this internally through its hasTip check (tooltip.tsx:88-89); a call-site {content && <Tooltip>} guard duplicates that logic and risks diverging from it.
Before you adopt this#
Five questions to answer before reaching for Tooltip.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Tooltip wraps its trigger; no shell already renders one for you.
- Does this app already ship a local implementation of the same thing? Timestamp, Button, Chip, and StatTile already wrap themselves; pass their own
tooltipprop instead. - Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Tooltip prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? A string
titlebecomes the trigger'saria-labelwhen it has no visible label. - Which canon §A rules bind this surface, and which does the composition break? §A9 makes an interactive thing a real, named control; §A6 needs a tip.
Travels with#
floating-ui (@floating-ui/react) is the positioning engine underneath both Tooltip and Dropdown; they share the same flip, shift, and portal conventions, which is why nesting one inside the other's boundary (a tooltip inside a modal, a dropdown inside a tooltip's trigger) behaves predictably.
FloatingArrow renders the pointer back to the trigger; its fill color is driven by the same color prop as the tip surface (tooltip.tsx:44-53).
Snippet#
// packages/ui/src/modal/modal-elements.tsx:74-80<Tooltip content="Close" placement="bottom"> <button className="btn btn-ghost btn-sm btn-square" aria-label="Close"> <IconFor.Close size={16} /> </button></Tooltip>