Skip to content

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.

tooltip/basic.tsx

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.

tooltip/placement.tsx

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.

tooltip/with-provider.tsx

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.

tooltip/delay.tsx

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.

tooltip/shortcut.tsx

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

Keyboard interactions
KeyBehaviour
TabMoves 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.
EscCloses the tooltip. Focus stays on the trigger, and that same focus will not reopen it; focus has to leave and come back.
EnterActivates the trigger. The tooltip closes on activation unless you pass closeOnClick={false}.
SpaceSame 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.

Theming tokens for Tooltip
PropertyControlsDefault
--forte-tooltip-z-indexStacking order of the positioner50
--forte-tooltip-bgPopup and arrow fillvar(--forte-gray-12)
--forte-tooltip-fgText colourvar(--forte-gray-1)
--forte-tooltip-radiusCorner radiusvar(--forte-radius-2)
--forte-tooltip-padding-blockTop and bottom paddingvar(--forte-space-1)
--forte-tooltip-padding-inlineLeft and right paddingvar(--forte-space-2)
--forte-tooltip-font-sizeText size, and the em basis the arrow is sized fromvar(--forte-font-size-1)
--forte-tooltip-font-weightText weightvar(--forte-font-weight-medium)
--forte-tooltip-shadowPopup shadow (removed in forced-colors mode)var(--forte-shadow-2)
--forte-tooltip-max-widthWidth cap, further clamped to the space the positioner reports as available18rem
--forte-tooltip-travelHow far the popup slides in from, per sidevar(--forte-travel-sm)
--forte-tooltip-arrow-widthArrow base width1em
--forte-tooltip-arrow-heightArrow height, and the distance it is pushed out from the popup0.5em
--forte-tooltip-shortcut-gapGap between the label and the key capvar(--forte-space-3)
--forte-tooltip-shortcut-bgKey cap fillcolor-mix(in oklab, currentColor 16%, transparent)
--forte-tooltip-shortcut-border-colorKey cap outlinecolor-mix(in oklab, currentColor 28%, transparent)
--forte-tooltip-shortcut-radiusKey cap corner radiusvar(--forte-radius-1)
--forte-tooltip-shortcut-padding-inlineKey cap left and right paddingvar(--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

Props for TooltipProvider
PropTypeDefaultDescription
childrenReactNodeThe subtree that shares this delay.
closeDelaynumber0How long to wait before closing a tooltip, in milliseconds.
delaynumber600How long to wait before opening a tooltip, in milliseconds. Applies to every tooltip below this provider; an individual `Tooltip.Trigger` can still override it.
timeoutnumber400Grouping 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

Props for TooltipRoot
PropTypeDefaultDescription
actionsRefRefObject<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.
childrenReactNode | PayloadChildRenderFunction<Payload>The trigger and popup for this tooltip. May also be a render function receiving `{ payload }` from the trigger that opened the tooltip.
defaultOpenbooleanfalseWhether the tooltip is open when it first mounts. For a controlled tooltip use `open` instead.
defaultTriggerIdstring | nullThe initially attached trigger id. Pairs with `defaultOpen` the way `triggerId` pairs with `open`.
disabledbooleanfalseWhether this tooltip is disabled. Disables the tooltip only — the trigger element stays fully interactive and does not receive the `disabled` attribute.
disableHoverablePopupbooleanfalseWhether 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.
handleTooltipHandle<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.
openbooleanWhether the tooltip is currently open. Pass this together with `onOpenChange` to control the tooltip.
trackCursorAxis"none" | "both" | "x" | "y"noneWhich axis the tooltip follows the cursor on. `'both'` turns it into a cursor-tracking label; combine with `side="bottom"` for the usual feel.
triggerIdstring | nullThe 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

Props for TooltipTrigger
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeDelaynumber0How long to wait before closing, in milliseconds.
closeOnClickbooleantrueWhether 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.
delaynumber600How long to wait before opening, in milliseconds. Overrides the provider's `delay` for this trigger only.
disabledbooleanfalseStops 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`.
handleTooltipHandle<Payload>Associates a detached trigger with a `Tooltip.Root` that carries the same handle, created once outside render with `Tooltip.createHandle()`.
payloadPayloadData handed to the tooltip when this trigger opens it, so one popup can render different content per trigger.
refRef<HTMLButtonElement>Forwarded to the underlying element.
renderReactElement<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.

Props for TooltipPopup
PropTypeDefaultDescription
alignAligncenterHow the tooltip lines up with the trigger along the chosen side.
alignOffsetnumber | OffsetFunction0Shifts the tooltip along the alignment axis, in pixels, or a function returning one.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullThe element the tooltip positions against, when it should not be the trigger.
arrowPaddingnumber5Minimum distance, in pixels, the arrow keeps from the tooltip's corners before it is allowed to sit off-centre (`data-uncentered`).
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
collisionAvoidanceCollisionAvoidanceHow the tooltip reacts when it would overflow the boundary — whether it flips, shifts, or stays put.
collisionBoundaryBoundaryclipping-ancestorsThe boundary the tooltip tries to stay inside of.
collisionPaddingPadding5Space, in pixels, kept between the tooltip and the collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullWhere 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.
disableAnchorTrackingbooleanfalseStops the tooltip re-measuring the anchor on scroll and resize. Cheaper, but the tooltip drifts if the anchor moves.
keepMountedbooleanfalseKeeps the portal in the DOM while the tooltip is closed.
positionerClassNamestringAdditional class name(s) for the positioner element, which owns placement and `z-index`. Use it to re-stack a single tooltip.
positionMethod"fixed" | "absolute"absoluteWhether the tooltip is positioned with `position: absolute` or `position: fixed`.
sideSidetopWhich side of the trigger to place the tooltip on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction.
sideOffsetnumber | OffsetFunction8Gap 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.
stickybooleanfalseKeeps the tooltip glued to the trigger while it scrolls out of view instead of letting it detach.

Tooltip.Arrow

Props for TooltipArrow
PropTypeDefaultDescription
childrenReactNodeReplaces the built-in wedge. The default SVG inherits the popup's colours through `--forte-tooltip-bg`, so a custom skin usually needs nothing here.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Tooltip.Shortcut

Props for TooltipShortcut
PropTypeDefaultDescription
childrenReactNodeThe keys, written the way they are printed — `⌘B`, `Ctrl+B`, `⇧⌘P`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
renderRenderProp<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.