Tooltip
A short label that appears next to a control on hover or keyboard focus. Reach for it when an icon-only button needs a name for sighted users, or when a dense toolbar has no room for visible text. Whatever it says has to be available somewhere else too — read the warning below before you reach for one.
Deciding between a tooltip and a popover comes down to the purpose of the
trigger. If the trigger's purpose is to open the popup — the little "i" info
icon is the usual case — it is a popover, not a tooltip,
and its content has to be reachable by touch and by assistive technology. Reach
for Popover with openOnHover on its trigger: pointer users get the same
hover-to-reveal feel, while the content stays announced and available on touch.
If the trigger's purpose is unrelated to the popup, and the popup merely labels
the control, it is a tooltip.
For transient feedback such as "Copied!", reach for Base UI's Toast with its
anchoring instead — unlike a tooltip, it is announced.
Import
import { Tooltip } from "@forte-ui/react";Examples
Sides
side on Tooltip.Popup picks the edge the tooltip opens from, and align
moves it along that edge. Both are hints: the tooltip flips to the opposite side
when it would overflow the collision boundary, which is why the left and
right tooltips below may land elsewhere in a narrow frame. Alongside the four
physical sides, "inline-start" and "inline-end" follow writing direction
instead of staying pinned to a physical edge.
Shared delay across a toolbar
Tooltip.Provider gives every tooltip beneath it one delay, and one grouping
window. Once the first tooltip in the group is showing, sweeping to a neighbour
opens the next one instantly instead of waiting out the delay again — that is
what makes a row of icon buttons feel like a single toolbar. That instant-open
window lasts timeout milliseconds (400 by default) after a tooltip closes;
come back later than that and the next one waits out the delay again. Mount one
provider near the root of your app — the one below is scoped to the demo so its
400 ms delay is the value you are seeing.
Tuning the delay
delay is how long the pointer must rest on the trigger before the tooltip
opens, in milliseconds; closeDelay is how long it lingers after the pointer
leaves. Set them on Tooltip.Provider for a whole subtree, or on an individual
Tooltip.Trigger to override the provider for that one trigger. delay={0}
opens on contact.
Note that neither delay applies to the keyboard: focusing a trigger opens its tooltip immediately.
Keyboard shortcuts
<Tooltip.Shortcut> prints the keys as a cap beside the label — the same
convenience <Menu.Shortcut> is on the menu side, for the
toolbar button that runs the same command. The cap is a
Kbd, re-tuned through the tooltip's own knobs, so it
renders a <kbd> carrying data-forte="kbd". Write the keys the way they are
printed (⌘B, Ctrl+B, ⇧⌘P); nothing here maps them per platform. One
shortcut anywhere in the popup turns it into a row, so the label and the cap
share a line without laying anything out.
The cap keeps the popup's own text colour and carries a translucent fill drawn
from it, rather than dimming the way a menu's shortcut does. A tooltip's
surface is the inverse of the page — near-white on near-black — and no mid-grey
on it is one of the pairs the contrast harness measures, so a dimmed shortcut
would be a guess at SC 1.4.3 on permanently visible text. Retune it with
--forte-tooltip-shortcut-bg and the three knobs beside it, which is also how a
re-skinned popup keeps its cap legible.
Accessibility
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the trigger, which opens the tooltip immediately. Only keyboard focus opens it — a trigger focused by a mouse click does not, because the trigger must match :focus-visible. |
| Esc | Closes the tooltip. Focus stays on the trigger, and that same focus will not reopen it; focus has to leave and come back. |
| Enter | Activates the trigger. The tooltip closes on activation unless you pass closeOnClick={false}. |
| Space | Same as Enter. Tooltip.Trigger always renders a button, and has no nativeButton prop to opt out. |
Tabbing away from the trigger closes the tooltip. The popup is rendered with
tabindex="-1", so it never enters the tab order and nothing inside it can be
reached with the keyboard — one more reason to keep it to a short label rather
than anything a user has to act on.
The popup carries no role="tooltip", and the trigger is not wired to it with
aria-describedby. That is deliberate rather than an omission to patch: the
popup is portalled and unmounted while closed, so the reference would dangle.
Treat the tooltip as decoration and label the trigger properly instead.
Two more things to get right. Because the tooltip never opens on touch, the icon
on an icon-only trigger has to carry its meaning on its own for mobile users —
pick a conventional glyph, not a clever one. And Tooltip.Root's disabled and
Tooltip.Trigger's disabled disable the tooltip, not the control: neither
sets the disabled attribute, so the button stays focusable and clickable. Only
data-trigger-disabled changes. To disable the control, pass disabled through
render.
In forced-colors mode the popup's shadow is stripped by the browser, so the component carries a transparent border that becomes the visible system-coloured boundary, and the arrow is drawn as two SVG paths rather than the traditional CSS-border triangle, which would degrade into a filled rectangle. Reduced-motion is handled by the motion tokens: the enter/exit transition collapses to a plain opacity fade with no travel and no scale.
Theming
Every value below is a custom property you can override. --forte-tooltip-z-index
is declared on the positioner; all the others are declared on the popup.
| Property | Controls | Default |
|---|---|---|
--forte-tooltip-z-index | Stacking order of the positioner | 50 |
--forte-tooltip-bg | Popup and arrow fill | var(--forte-gray-12) |
--forte-tooltip-fg | Text colour | var(--forte-gray-1) |
--forte-tooltip-radius | Corner radius | var(--forte-radius-2) |
--forte-tooltip-padding-block | Top and bottom padding | var(--forte-space-1) |
--forte-tooltip-padding-inline | Left and right padding | var(--forte-space-2) |
--forte-tooltip-font-size | Text size, and the em basis the arrow is sized from | var(--forte-font-size-1) |
--forte-tooltip-font-weight | Text weight | var(--forte-font-weight-medium) |
--forte-tooltip-shadow | Popup shadow (removed in forced-colors mode) | var(--forte-shadow-2) |
--forte-tooltip-max-width | Width cap, further clamped to the space the positioner reports as available | 18rem |
--forte-tooltip-travel | How far the popup slides in from, per side | var(--forte-travel-sm) |
--forte-tooltip-arrow-width | Arrow base width | 1em |
--forte-tooltip-arrow-height | Arrow height, and the distance it is pushed out from the popup | 0.5em |
--forte-tooltip-shortcut-gap | Gap between the label and the key cap | var(--forte-space-3) |
--forte-tooltip-shortcut-bg | Key cap fill | color-mix(in oklab, currentColor 16%, transparent) |
--forte-tooltip-shortcut-border-color | Key cap outline | color-mix(in oklab, currentColor 28%, transparent) |
--forte-tooltip-shortcut-radius | Key cap corner radius | var(--forte-radius-1) |
--forte-tooltip-shortcut-padding-inline | Key cap left and right padding | var(--forte-space-1) |
The stylesheet also computes --forte-tooltip-enter-x and --forte-tooltip-enter-y
from --forte-tooltip-travel and the resolved data-side. They are internal;
change --forte-tooltip-travel instead.
Where you set these matters. Every popup property is declared on the popup's own
element, so setting one on an ancestor, on :root or on a theme scope has no
effect — the element's own declaration wins over an inherited value. Override
them on the popup itself, by passing className to Tooltip.Popup (an
unlayered rule beats the library's @layer forte.components, whatever its
specificity). The portal makes the ancestor route doubly useless: the popup
renders on document.body, so nothing above the trigger is an ancestor of it
in the first place.
To re-skin every tooltip at once, re-point the global tokens the defaults
resolve to — --forte-gray-*, --forte-radius-*, --forte-space-*, --forte-font-*,
--forte-shadow-*, --forte-travel-sm. The popup only reads those and never
re-declares them, so a value set on :root or a theme scope does reach it. The
knobs with literal defaults — --forte-tooltip-max-width,
--forte-tooltip-arrow-width, --forte-tooltip-arrow-height, and the shortcut
cap's --forte-tooltip-shortcut-bg and --forte-tooltip-shortcut-border-color,
mixed from the popup's own currentColor — have no global token behind them,
so those can only be changed on the popup itself.
--forte-tooltip-z-index is declared on the positioner, not the popup, so
restacking a single tooltip goes through positionerClassName rather than
className.
API reference
Tooltip.Provider
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The subtree that shares this delay. | |
closeDelay | number | 0 | How long to wait before closing a tooltip, in milliseconds. |
delay | number | 600 | How long to wait before opening a tooltip, in milliseconds. Applies to every tooltip below this provider; an individual `Tooltip.Trigger` can still override it. |
timeout | number | 400 | Grouping window, in milliseconds. If one tooltip closes and another opens within this window, the second one appears instantly instead of waiting out `delay` again — which is what makes a row of icon buttons feel like a single toolbar rather than a series of separate waits. |
Tooltip.Root
| Prop | Type | Default | Description |
|---|---|---|---|
actionsRef | RefObject<TooltipRootActions | null> | Imperative escape hatch. `unmount()` forces the popup out of the DOM (use it when an exit animation is driven externally); `close()` closes the tooltip. | |
children | ReactNode | PayloadChildRenderFunction<Payload> | The trigger and popup for this tooltip. May also be a render function receiving `{ payload }` from the trigger that opened the tooltip. | |
defaultOpen | boolean | false | Whether the tooltip is open when it first mounts. For a controlled tooltip use `open` instead. |
defaultTriggerId | string | null | The initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`. | |
disabled | boolean | false | Whether this tooltip is disabled. Disables the tooltip only — the trigger element stays fully interactive and does not receive the `disabled` attribute. |
disableHoverablePopup | boolean | false | Whether the popup can be hovered without closing. Off by default: a tooltip is not meant to hold interactive content, and a hoverable popup gets in the way of the content beneath it. |
handle | TooltipHandle<Payload> | Associates this tooltip with detached triggers created through `Tooltip.createHandle()`, so one popup can serve several triggers that live elsewhere in the tree. | |
onOpenChange | ((open: boolean, eventDetails: TooltipRootChangeEventDetails) => void) | Called when the tooltip wants to open or close. The second argument carries the reason (`'trigger-hover'`, `'trigger-focus'`, `'trigger-press'`, `'outside-press'`, `'escape-key'`, `'disabled'`, `'imperative-action'`, `'none'`) and can `cancel()` the change. | |
onOpenChangeComplete | ((open: boolean) => void) | Called after the open or close transition has finished. Use this rather than a timer when work has to wait for the popup to actually leave. | |
open | boolean | Whether the tooltip is currently open. Pass this together with `onOpenChange` to control the tooltip. | |
trackCursorAxis | "none" | "both" | "x" | "y" | none | Which axis the tooltip follows the cursor on. `'both'` turns it into a cursor-tracking label; combine with `side="bottom"` for the usual feel. |
triggerId | string | null | The trigger the tooltip is attached to, for controlled multi-trigger setups. There is no separate `onTriggerIdChange` — derive the new id from `eventDetails.trigger` inside `onOpenChange`. |
Tooltip.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeDelay | number | 0 | How long to wait before closing, in milliseconds. |
closeOnClick | boolean | true | Whether clicking the trigger closes the tooltip. Leave it on for triggers that do something on click — the tooltip has served its purpose by then and would otherwise sit over the result. |
delay | number | 600 | How long to wait before opening, in milliseconds. Overrides the provider's `delay` for this trigger only. |
disabled | boolean | false | Stops this trigger from opening the tooltip. It does **not** apply the `disabled` attribute to the element, so the button stays focusable and clickable; style the difference from `data-trigger-disabled`. To disable the control itself, pass `disabled` through `render`. |
handle | TooltipHandle<Payload> | Associates a detached trigger with a `Tooltip.Root` that carries the same handle, created once outside render with `Tooltip.createHandle()`. | |
payload | Payload | Data handed to the tooltip when this trigger opens it, so one popup can render different content per trigger. | |
ref | Ref<HTMLButtonElement> | Forwarded to the underlying element. | |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, TooltipTriggerState> | Replaces the rendered `<button>` with another element or component — `render={<Button variant="ghost" />}` is the common case. Note there is no `nativeButton` prop on this part: it always renders button semantics. |
Tooltip.Popup
Renders the portal, the positioner and the popup as one part, so its props are drawn from all three.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How the tooltip lines up with the trigger along the chosen side. |
alignOffset | number | OffsetFunction | 0 | Shifts the tooltip along the alignment axis, in pixels, or a function returning one. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | The element the tooltip positions against, when it should not be the trigger. | |
arrowPadding | number | 5 | Minimum distance, in pixels, the arrow keeps from the tooltip's corners before it is allowed to sit off-centre (`data-uncentered`). |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
collisionAvoidance | CollisionAvoidance | How the tooltip reacts when it would overflow the boundary — whether it flips, shifts, or stays put. | |
collisionBoundary | Boundary | clipping-ancestors | The boundary the tooltip tries to stay inside of. |
collisionPadding | Padding | 5 | Space, in pixels, kept between the tooltip and the collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Where the portal renders. Defaults to `document.body`; point it at a container when the tooltip has to live inside a specific stacking or shadow root. | |
disableAnchorTracking | boolean | false | Stops the tooltip re-measuring the anchor on scroll and resize. Cheaper, but the tooltip drifts if the anchor moves. |
keepMounted | boolean | false | Keeps the portal in the DOM while the tooltip is closed. |
positionerClassName | string | Additional class name(s) for the positioner element, which owns placement and `z-index`. Use it to re-stack a single tooltip. | |
positionMethod | "fixed" | "absolute" | absolute | Whether the tooltip is positioned with `position: absolute` or `position: fixed`. |
side | Side | top | Which side of the trigger to place the tooltip on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction. |
sideOffset | number | OffsetFunction | 8 | Gap between trigger and tooltip, in pixels, or a function returning one. When an `Arrow` is rendered this must exceed the arrow's height or the arrow will overlap the trigger; the default leaves room for the default arrow. |
sticky | boolean | false | Keeps the tooltip glued to the trigger while it scrolls out of view instead of letting it detach. |
Tooltip.Arrow
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Replaces the built-in wedge. The default SVG inherits the popup's colours through `--forte-tooltip-bg`, so a custom skin usually needs nothing here. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Tooltip.Shortcut
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The keys, written the way they are printed — `⌘B`, `Ctrl+B`, `⇧⌘P`. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
render | RenderProp<Record<string, unknown>> | Replaces the rendered `<kbd>` with another element or component. The default tag is already the semantically right one, so reach for this only when a host component needs the cap to be one of its own parts. |