Latticecomponents

Tooltip

A hover- and focus-triggered surface with open delay, popper positioning and portal layering.

@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, stat breakdowns. It opens on hover or gamepad selection and waits a beat before showing, skipping that wait between nearby triggers. It anchors against the trigger with collision-aware placement, and dismisses when the pointer or selection leaves.

Preview

Edit

Import

import { Tooltip } from "@lattice-ui/react-tooltip";

Anatomy

Root, Trigger, Portal and Content form the working tooltip. Wrap a region in Provider to share open-delay behaviour 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

A provider for timing, a trigger projected onto your own button, and a label surface inside the portal. Hovering waits out the delay, then the content animates in above the button.

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 the same skip window. The first hover waits the full delayDuration. Once any tooltip has opened, a neighbouring trigger opens within skipDelayDuration instead.

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 one line. Keep it non-interactive — it closes as soon as the pointer leaves the trigger, so buttons inside 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, sideOffset the gap from the trigger, alignOffset a slide along that side, collisionPadding the distance from the viewport edges. Popper flips or shifts on collision, and the entrance recipe follows the side that 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 decides visibility — an onboarding step pinning a hint open. Hover and selection still report through onOpenChange, so you choose whether to honour them.

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. Nothing extra to wire — make the trigger selectable and D-pad focus shows the tooltip as soon as 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 and selection focus. The tooltip opens when either becomes active and closes only when both are inactive. Moving the mouse off a still-selected trigger keeps it up. Hover opens go through the delay. Selection opens skip it.

disabled drops open actions while still tracking activity, closes immediately when flipped mid-hover, and clears Active and Selectable.

Open delay and skip window

The delay resolves from Root’s delayDuration if set, otherwise the Provider’s, which defaults to 700 ms. 0 or less opens synchronously. Leaving the trigger cancels a pending open, and so does unmounting the root.

The skip window lives on the provider. Every open stamps a timestamp, and a hover open starting within skipDelayDuration, default 300 ms, waits at most that long. Moving between adjacent triggers does not re-wait the full delay.

Positioning

Tooltip.Content positions itself with popper against the trigger ref, taking placement, sideOffset, alignOffset and collisionPadding. Until popper has measured it, the content is parked off-screen and the entrance held back.

Layering

Tooltip.Portal renders the content into a ScreenGui outside the local tree, reusing the surrounding portal context. container or displayOrderBase switches the portal onto its own provider.

Dismissal

The content is a non-modal dismissable layer: it never blocks interaction behind it, and an outside interaction closes it after onPointerDownOutside and onInteractOutside fire. Dismissal goes through the same setOpen path as hover, so a controlled tooltip sees onOpenChange(false).

Motion and presence

The content wraps its children in a frame and runs no motion of its own. createPopperEntranceRecipe(placement) keyed to the resolved placement slides and fades from the trigger’s actual side, even after a flip. forceMount keeps it mounted while closed. Under asChild, your child renders inside the positioned surface with its position zeroed and 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.