Latticecomponents

Tooltip

Hover- and focus-triggered surface primitive that owns open delay, popper positioning, portal layering, and presence motion while you own the content.

@lattice-ui/react-tooltipStable directionimport Tooltipdepends on runtime, layer, motion, popper

Tooltip 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.

Edit

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 anatomy
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger />
<Tooltip.Portal>
<Tooltip.Content />
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
PartRequiredResponsibility
Tooltip.ProvidernoShares delay defaults and the skip-delay grace window across the tooltips inside it.
Tooltip.RootyesOwns open state and the delayed-open / close logic; shares trigger and content refs.
Tooltip.TriggeryesThe element whose hover/selection opens and closes the tooltip; the popper anchor.
Tooltip.PortalyesRenders the content into a ScreenGui outside the local tree.
Tooltip.ContentyesThe 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.

StatTooltip.tsx
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.

ActionToolbar.tsx
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.

KeybindHint.tsx
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.

InventorySlotTooltip.tsx
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.

OnboardingHint.tsx
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>
);
}

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.

GamepadStatTooltip.tsx
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.

API reference

Tooltip.Provider

PropTypeDescription
delayDurationnumberDefault hover-open delay in milliseconds for tooltips inside this provider. A root's own delayDuration overrides it per tooltip. Defaults to 700.
skipDelayDurationnumberGrace 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.
childrenReact.ReactNodeThe tooltips that share these delay defaults and the skip window.

Tooltip.Root

PropTypeDescription
openbooleanControlled open state. Pair with onOpenChange; hover, selection, and outside dismissal still report through it.
defaultOpenbooleanInitial open state for uncontrolled usage. Defaults to false.
delayDurationnumberHover-open delay for this tooltip in milliseconds, overriding the provider. Use 0 for an instant tooltip.
onOpenChange(open: boolean) => voidCalled whenever the open state changes (or is requested to change, in controlled mode).
childrenReact.ReactNodeThe trigger, portal, and content parts.

Tooltip.Trigger

PropTypeDescription
disabledbooleanDrops open actions, closes when activity ends, and clears Active/Selectable so the trigger leaves gamepad selection. Defaults to false.
asChildbooleanMerge the hover/selection handlers, Active/Selectable, and the popper anchor ref onto the single child element instead of the textbutton the part renders.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Tooltip.Portal

PropTypeDescription
containerBasePlayerGuiTarget PlayerGui to render the content into. Defaults to the surrounding portal context's container.
displayOrderBasenumberBase DisplayOrder for the generated ScreenGui, used to order it against other layers.
childrenReact.ReactNodeThe content part.

Tooltip.Content

PropTypeDescription
placementPopperPlacementPreferred side relative to the trigger: "top", "bottom", "left", or "right". Popper may flip it on collision.
sideOffsetnumberGap in pixels between the content and the trigger along the placement side.
alignOffsetnumberShift in pixels along the placement side.
collisionPaddingnumberMinimum padding kept between the content and the viewport edges when resolving position.
transitionMotionConfigReveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) keyed to the resolved placement.
forceMountbooleanKeeps the content mounted while closed and through exit motion, bypassing the presence wrapper. Defaults to false.
onPointerDownOutside(event: LayerInteractEvent) => voidCalled when a pointer press occurs outside the content, before dismissal.
onInteractOutside(event: LayerInteractEvent) => voidCalled for any other outside interaction, before dismissal.
asChildbooleanRender the single child element inside the positioned surface; its position is zeroed and its Visible follows the motion state.
childrenReact.ReactNodeThe tooltip contents.