# Tooltip

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

Source: https://docs.astra-void.xyz/lattice-ui/components/tooltip/

`@lattice-ui/react-tooltip` · Stable direction · import `Tooltip` · depends 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.

_Interactive preview._

## Import

```ts
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.

```tsx title="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.

```tsx title="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.

```tsx title="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.

```tsx title="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.

```tsx title="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.

```tsx title="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>
  );
}
```

> **Outside interactions still dismiss**
>
> 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.

```tsx title="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.

> **The skip window needs a Provider**
>
> 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.

> **Selection opens skip the delay**
>
> 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.

> **The default trigger is a transparent placeholder**
>
> 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. |

## Related

- [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
