@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, 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
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 in Provider to
share open-delay behaviour 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
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.
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.
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.
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.
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.
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 controlled, the content is a dismissable layer, so an outside press asks it to close through onOpenChange(false). Keep your open true to ignore the request.
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.
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.
Without a Tooltip.Provider each tooltip still gets the 700 ms default delay, but there is no shared skip window, so every hover re-waits it.
Only hover opens are delayed. SelectionGained opens immediately, and a pending hover open resolves at once when selection arrives.
Without asChild, Tooltip.Trigger renders a transparent 140x36 placeholder — handy for wiring, not for shipping. Pass asChild to project the hover and selection handlers and the anchor ref onto your own button. The slot’s Active and Selectable follow disabled.
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. |