@lattice-ui/react-tooltipStable directionimport Tooltipdepends on runtime, layer, motion, popperTooltip is the primitive for a small, transient surface that explains a control: button hints, icon labels, and stat breakdowns. It opens on hover or gamepad selection and positions itself against the trigger with popper, so your component only renders the label.
Reach for Tooltip when a surface should appear on hover or selection, wait a beat before showing (and skip that wait when moving between nearby triggers), anchor to its trigger with collision-aware placement, and dismiss when the pointer or selection leaves.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { Tooltip } from "@lattice-ui/react-tooltip";import { buttonRecipe } from "../../../playground/src/client/theme/recipes";
function ToolbarTooltip(props: { label: string; layoutOrder: number; shortcut: string }) { const { theme } = useTheme();
return ( <Tooltip.Root> <Tooltip.Trigger asChild> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "surface", size: "sm" }, theme), { LayoutOrder: props.layoutOrder, Size: UDim2.fromOffset(76, 36), Text: props.label, TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> </textbutton> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content asChild placement="top" sideOffset={8}> <frame BackgroundColor3={theme.colors.overlay} BorderSizePixel={0} Size={UDim2.fromOffset(76, 30)}> <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <Text BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={props.shortcut} TextColor3={theme.colors.accentContrast} TextSize={theme.typography.labelSm.textSize} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> );}
function TooltipExample() { const { theme } = useTheme();
return ( <Tooltip.Provider delayDuration={300} skipDelayDuration={150}> <frame BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, theme.space[8])} VerticalAlignment={Enum.VerticalAlignment.Center} /> <ToolbarTooltip label="Cut" layoutOrder={0} shortcut="Ctrl+X" /> <ToolbarTooltip label="Copy" layoutOrder={1} shortcut="Ctrl+C" /> <ToolbarTooltip label="Paste" layoutOrder={2} shortcut="Ctrl+V" /> </frame> </Tooltip.Provider> );}Import
import { Tooltip } from "@lattice-ui/react-tooltip";Anatomy
Root, Trigger, Portal, and Content form the working tooltip. Wrap a region (or your whole app) in Provider to share open-delay behavior across many tooltips.
Tooltip anatomy
<Tooltip.Provider> <Tooltip.Root> <Tooltip.Trigger /> <Tooltip.Portal> <Tooltip.Content /> </Tooltip.Portal> </Tooltip.Root></Tooltip.Provider>| Part | Required | Responsibility |
|---|---|---|
Tooltip.Provider | no | Shares delay defaults and the skip-delay grace window across the tooltips inside it. |
Tooltip.Root | yes | Owns open state and the delayed-open / close logic; shares trigger and content refs. |
Tooltip.Trigger | yes | The element whose hover/selection opens and closes the tooltip; the popper anchor. |
Tooltip.Portal | yes | Renders the content into a ScreenGui outside the local tree. |
Tooltip.Content | yes | The popper-positioned, motion-driven, dismissable surface. |
Examples
Basic usage
The smallest useful tooltip: a provider for timing, a trigger projected onto your own button with asChild, and an app-owned label surface inside the portal. Hovering the button waits out the provider delay, then the content animates in above it; moving the pointer away closes it.
import { Tooltip } from "@lattice-ui/react-tooltip";
export function StatTooltip() { return ( <Tooltip.Provider delayDuration={500}> <Tooltip.Root> <Tooltip.Trigger asChild> <textbutton Text="ATK 142" Size={UDim2.fromOffset(96, 32)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="top" sideOffset={8}> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(200, 56)} > <uicorner CornerRadius={new UDim(0, 8)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Base 120 + 22 from gear" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> </Tooltip.Provider> );}Shared delay across a toolbar
One Provider around a row of icon buttons gives every tooltip the same delay and — more importantly — the skip window. The first hover waits the full delayDuration; once any tooltip has opened, moving to a neighboring trigger within skipDelayDuration opens its tooltip after at most that short window, so scrubbing across the toolbar feels instant.
import { Tooltip } from "@lattice-ui/react-tooltip";
const ACTIONS = [ { icon: "rbxassetid://111111", label: "Attack" }, { icon: "rbxassetid://222222", label: "Defend" }, { icon: "rbxassetid://333333", label: "Flee" },];
export function ActionToolbar() { return ( <Tooltip.Provider delayDuration={600} skipDelayDuration={250}> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(160, 44)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
{ACTIONS.map((action) => ( <Tooltip.Root key={action.label}> <Tooltip.Trigger asChild> <imagebutton Image={action.icon} Size={UDim2.fromOffset(44, 44)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="top" sideOffset={6}> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(88, 28)}> <uicorner CornerRadius={new UDim(0, 6)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={action.label} TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> ))} </frame> </Tooltip.Provider> );}Rich content
Tooltip.Content accepts arbitrary children, so a tooltip can be a small card rather than a single line — here a keybind hint with a title row and a description. Keep it non-interactive: the tooltip closes as soon as the pointer leaves the trigger, so buttons inside it are unreachable by design.
import { Tooltip } from "@lattice-ui/react-tooltip";
export function KeybindHint() { return ( <Tooltip.Root> <Tooltip.Trigger asChild> <textbutton Text="Dash" Size={UDim2.fromOffset(96, 32)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="right" sideOffset={10}> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 72)}> <uicorner CornerRadius={new UDim(0, 8)} /> <uipadding PaddingLeft={new UDim(0, 10)} PaddingTop={new UDim(0, 8)} /> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(200, 20)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 6)} VerticalAlignment={Enum.VerticalAlignment.Center} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(48, 18)} Text="Dash" TextColor3={Color3.fromRGB(240, 244, 250)} TextXAlignment={Enum.TextXAlignment.Left} /> <frame BackgroundColor3={Color3.fromRGB(59, 66, 84)} Size={UDim2.fromOffset(28, 18)}> <uicorner CornerRadius={new UDim(0, 4)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Q" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </frame>
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(200, 34)} Text="Burst forward. Grants brief invulnerability while moving." TextColor3={Color3.fromRGB(178, 186, 200)} TextWrapped={true} TextXAlignment={Enum.TextXAlignment.Left} TextYAlignment={Enum.TextYAlignment.Top} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> );}Placement and offset tuning
placement picks the preferred side ("top", "bottom", "left", "right"), sideOffset sets the gap from the trigger, alignOffset slides the content along that side, and collisionPadding keeps it off the viewport edges. Popper flips or shifts away from the preferred side when it would collide, and the default entrance recipe follows the side that was actually resolved.
import { Tooltip } from "@lattice-ui/react-tooltip";
export function InventorySlotTooltip() { return ( <Tooltip.Root> <Tooltip.Trigger asChild> <imagebutton Image="rbxassetid://444444" Size={UDim2.fromOffset(56, 56)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="right" sideOffset={12} alignOffset={-8} collisionPadding={16} > <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(180, 64)}> <uicorner CornerRadius={new UDim(0, 8)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Iron Greatsword" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> );}Controlled and force-open
Pass open and onOpenChange when something outside the tooltip should decide visibility — an onboarding step that pins a hint open until acknowledged, or a tutorial that walks through controls. Hover and selection still report through onOpenChange, so you choose whether to honor them; here they are ignored while the onboarding step is active.
import { useState } from "@rbxts/react";import { Tooltip } from "@lattice-ui/react-tooltip";
export function OnboardingHint() { const [showHint, setShowHint] = useState(true);
return ( <Tooltip.Root open={showHint} onOpenChange={() => { // Ignore hover/selection/outside requests; only the button below closes it. }} > <Tooltip.Trigger asChild> <textbutton Text="Craft" Size={UDim2.fromOffset(96, 32)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="bottom" sideOffset={8}> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 84)}> <uicorner CornerRadius={new UDim(0, 8)} /> <uipadding PaddingLeft={new UDim(0, 10)} PaddingTop={new UDim(0, 8)} /> <uilistlayout Padding={new UDim(0, 6)} SortOrder={Enum.SortOrder.LayoutOrder} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(200, 32)} Text="New! Combine materials here to craft gear." TextColor3={Color3.fromRGB(240, 244, 250)} TextWrapped={true} TextXAlignment={Enum.TextXAlignment.Left} /> <textbutton BackgroundColor3={Color3.fromRGB(88, 142, 255)} Event={{ Activated: () => setShowHint(false) }} Size={UDim2.fromOffset(72, 24)} Text="Got it" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> );}Even in controlled mode the content runs as a dismissable layer: a press outside asks the tooltip to close through onOpenChange(false). If the hint must survive outside presses, keep your controlled open true and ignore those change requests, as above.
Gamepad selection
The trigger listens to SelectionGained/SelectionLost alongside hover, and selection opens are immediate — no delay — because a gamepad user has already committed to the control. Nothing extra to wire: make the trigger selectable (the default trigger and the asChild slot both set Selectable) and D-pad focus shows the tooltip the moment the control is selected.
import { Tooltip } from "@lattice-ui/react-tooltip";
export function GamepadStatTooltip() { return ( <Tooltip.Root> {/* Selection lands here via the gamepad; the tooltip opens instantly. */} <Tooltip.Trigger asChild> <textbutton Text="Crit 24%" Size={UDim2.fromOffset(96, 32)} /> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Content placement="top" sideOffset={8}> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(200, 48)}> <uicorner CornerRadius={new UDim(0, 8)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Chance to deal double damage" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Tooltip.Content> </Tooltip.Portal> </Tooltip.Root> );}How it behaves
Open behavior
Tooltip.Trigger tracks two activity sources — hover (MouseEnter/MouseLeave) and selection focus (SelectionGained/SelectionLost). The tooltip opens when either source becomes active and closes only when both are inactive, so moving the mouse off a still-selected trigger keeps the tooltip up. Hover opens go through the delay; selection opens call straight into open with no delay, which suits gamepad and keyboard navigation.
Setting disabled on the trigger stops it from opening (activity is still tracked, but open actions are dropped), closes the tooltip as soon as activity ends, and flipping disabled to true mid-hover resets the activity state and closes immediately. A disabled trigger also clears Active and Selectable, removing it from gamepad selection.
Open delay and skip window
Hover opens wait for a delay before showing. The delay resolves from Root’s delayDuration if set, otherwise the Provider’s delayDuration (default 700 ms). A resolved delay of 0 (or less) opens synchronously — set delayDuration={0} on a root for an instant tooltip. Leaving the trigger cancels any pending open, and unmounting the root cancels it too.
The skip window lives on the provider: every open — hover, selection, or a controlled open through the trigger — stamps a timestamp, and when the next hover open starts within skipDelayDuration (default 300 ms) of it, the wait is shortened to at most that skip window. Moving between adjacent triggers under one provider therefore feels instant instead of re-waiting the full delay.
Positioning
Tooltip.Content positions itself with popper, anchored to the trigger ref. Set placement for the preferred side ("top", "bottom", "left", or "right"), sideOffset for the gap from the trigger, alignOffset to shift along the side, and collisionPadding for the minimum distance kept from the viewport edges. Until popper has measured and positioned the content it is parked far off-screen, so it never flashes at the wrong spot; the entrance motion also waits for positioning before it plays.
Layering
Tooltip.Portal renders the content into a ScreenGui outside the local component tree. With no props it reuses the surrounding portal context; pass container to target a specific PlayerGui or displayOrderBase to order the generated ScreenGui against other layered surfaces — either prop switches the portal onto its own provider with those overrides.
Dismissal
Tooltip.Content runs as a non-modal dismissable layer: it never blocks interaction behind it, and an outside interaction closes it. onPointerDownOutside fires for outside presses and onInteractOutside for other outside interactions, both before the close, so you can observe (or record) the interaction. Dismissal goes through the same setOpen path as hover, so controlled tooltips see it as an onOpenChange(false) request.
Motion and presence
Tooltip.Content wraps its children in a frame and runs no motion of its own. Pass a transition to animate it — createPopperEntranceRecipe(placement) keyed to the resolved placement slides and fades from the trigger’s actual side, even after a collision flip. Pass forceMount to keep the content mounted while closed and through its exit, bypassing the presence wrapper when you drive visibility yourself. With asChild, your single child is rendered inside the positioned surface with its position zeroed and its Visible bound to the motion state.
Without a Tooltip.Provider, each tooltip still gets the 700 ms default delay, but there is no shared skip window — every hover re-waits the full delay. Wrap a region in a provider whenever neighboring tooltips should hand off instantly, and use Root’s own delayDuration for per-tooltip overrides.
Only hover opens are delayed. SelectionGained opens immediately, so gamepad users never wait — and if a delayed hover open is pending when selection arrives, the open simply happens right away. Design your content to be readable at instant-open speeds.
Without asChild, Tooltip.Trigger renders a transparent 140x36 textbutton labeled “Tooltip Trigger” — handy for wiring, not for shipping. In real UI, pass asChild and project the hover/selection handlers and anchor ref onto your own button; the slot’s Active and Selectable follow the disabled prop.
API reference
Tooltip.Provider
| Prop | Type | Description |
|---|---|---|
| delayDuration | number | Default hover-open delay in milliseconds for tooltips inside this provider. A root's own delayDuration overrides it per tooltip. Defaults to 700. |
| skipDelayDuration | number | Grace window in milliseconds after any tooltip opens; hover opens starting within it wait at most this long instead of the full delay. Defaults to 300. |
| children | React.ReactNode | The tooltips that share these delay defaults and the skip window. |
Tooltip.Root
| Prop | Type | Description |
|---|---|---|
| open | boolean | Controlled open state. Pair with onOpenChange; hover, selection, and outside dismissal still report through it. |
| defaultOpen | boolean | Initial open state for uncontrolled usage. Defaults to false. |
| delayDuration | number | Hover-open delay for this tooltip in milliseconds, overriding the provider. Use 0 for an instant tooltip. |
| onOpenChange | (open: boolean) => void | Called whenever the open state changes (or is requested to change, in controlled mode). |
| children | React.ReactNode | The trigger, portal, and content parts. |
Tooltip.Trigger
| Prop | Type | Description |
|---|---|---|
| disabled | boolean | Drops open actions, closes when activity ends, and clears Active/Selectable so the trigger leaves gamepad selection. Defaults to false. |
| asChild | boolean | Merge the hover/selection handlers, Active/Selectable, and the popper anchor ref onto the single child element instead of the textbutton the part renders. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Tooltip.Portal
| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Target PlayerGui to render the content into. Defaults to the surrounding portal context's container. |
| displayOrderBase | number | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. |
| children | React.ReactNode | The content part. |
Tooltip.Content
| Prop | Type | Description |
|---|---|---|
| placement | PopperPlacement | Preferred side relative to the trigger: "top", "bottom", "left", or "right". Popper may flip it on collision. |
| sideOffset | number | Gap in pixels between the content and the trigger along the placement side. |
| alignOffset | number | Shift in pixels along the placement side. |
| collisionPadding | number | Minimum padding kept between the content and the viewport edges when resolving position. |
| transition | MotionConfig | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) keyed to the resolved placement. |
| forceMount | boolean | Keeps the content mounted while closed and through exit motion, bypassing the presence wrapper. Defaults to false. |
| onPointerDownOutside | (event: LayerInteractEvent) => void | Called when a pointer press occurs outside the content, before dismissal. |
| onInteractOutside | (event: LayerInteractEvent) => void | Called for any other outside interaction, before dismissal. |
| asChild | boolean | Render the single child element inside the positioned surface; its position is zeroed and its Visible follows the motion state. |
| children | React.ReactNode | The tooltip contents. |