# Popover

> Anchored surface primitive that owns open state, popper positioning, layered dismissal, and presence motion while you own the visuals.

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

`@lattice-ui/react-popover` · Stable direction · import `Popover` · depends on `runtime`, `focus`, `layer`, `motion`, `popper`

Popover is the primitive for a non-blocking surface that floats next to the element that opened it: hover cards, inline editors, detail panels, and small forms. It coordinates open state, positioning, dismissal, and exit motion so your component only has to render the floating frame and its contents.

Reach for Popover when a surface should **anchor** to a trigger (or a separate anchor), **position itself** with the popper foundation, and **dismiss predictably** — by an explicit close, an outside interaction, or a controlled state change. Unlike Dialog, Popover is **non-modal by default**, so the rest of the UI stays interactive while it is open.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

_Interactive preview._

## Import

```ts
import { Popover } from "@lattice-ui/react-popover";
```

## Anatomy

Compose the parts you need. `Root`, `Portal`, and `Content` form the minimum useful surface; `Trigger`, `Anchor`, and `Close` are optional depending on how you drive and position the popover.

```tsx title="Popover anatomy"
<Popover.Root>
  <Popover.Trigger />
  <Popover.Anchor />
  <Popover.Portal>
    <Popover.Content>
      <Popover.Close />
    </Popover.Content>
  </Popover.Portal>
</Popover.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `Popover.Root` | yes | Owns open state and shares it with every part through context. |
| `Popover.Trigger` | no | A button that toggles the popover and acts as the default positioning anchor. |
| `Popover.Anchor` | no | An explicit anchor to position against when the trigger is not the right reference. |
| `Popover.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. |
| `Popover.Content` | yes | The positioned, dismissable, motion-driven surface. |
| `Popover.Close` | no | A button that closes the popover from inside the content. |

## Examples

### Basic popover

The smallest useful popover: an uncontrolled root, a trigger projected onto your own button with `asChild`, and a content surface in a portal. The trigger toggles on activation and doubles as the positioning anchor, an outside press dismisses, and the default content wrapper is an auto-sized `frame` — so your frame inside it just needs a size.

```tsx title="EmoteInfoPopover.tsx"
import { Popover } from "@lattice-ui/react-popover";

export function EmoteInfoPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <textbutton Text="Emote details" Size={UDim2.fromOffset(140, 38)} />
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content>
          <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 96)}>
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(200, 80)}
              Text="Wave — unlocked at level 5"
              TextColor3={Color3.fromRGB(236, 241, 249)}
              TextWrapped={true}
            />
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Detached anchor

Add a `Popover.Anchor` when the surface should point at something other than the button that opens it. Here a help button in a toolbar opens a callout that annotates the quest tracker HUD frame instead of the button itself. A mounted anchor always wins over the trigger as the positioning reference, and with `asChild` it tracks your element directly instead of the frame it renders.

```tsx title="QuestTrackerCallout.tsx"
import { Popover } from "@lattice-ui/react-popover";

export function QuestTrackerCallout() {
  return (
    <Popover.Root>
      <Popover.Anchor asChild>
        <frame
          AnchorPoint={new Vector2(1, 0)}
          BackgroundColor3={Color3.fromRGB(24, 26, 32)}
          Position={UDim2.new(1, -16, 0, 16)}
          Size={UDim2.fromOffset(240, 110)}
        >
          <textlabel
            BackgroundTransparency={1}
            Size={UDim2.fromOffset(220, 24)}
            Text="Active quest: Ember Vault"
            TextColor3={Color3.fromRGB(236, 241, 249)}
          />
        </frame>
      </Popover.Anchor>

      <Popover.Trigger asChild>
        <textbutton Text="What is this?" Size={UDim2.fromOffset(110, 30)} />
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content placement="left" sideOffset={12}>
          <frame BackgroundColor3={Color3.fromRGB(30, 33, 41)} Size={UDim2.fromOffset(200, 90)}>
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(180, 74)}
              Text="The quest tracker shows your current objective and reward."
              TextColor3={Color3.fromRGB(236, 241, 249)}
              TextWrapped={true}
            />
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Controlled by game state

Pass `open` and `onOpenChange` when something outside the popover decides visibility. This onboarding hint opens the moment the player receives their first item and closes through the same channel a trigger would use — an outside press or `Popover.Close` still calls `onOpenChange(false)`, so your state stays the source of truth. No trigger is needed; the anchor alone positions the surface.

```tsx title="InventoryHint.tsx"
import { Popover } from "@lattice-ui/react-popover";

export function InventoryHint(props: { hasNewItem: boolean; onDismiss: () => void }) {
  return (
    <Popover.Root
      open={props.hasNewItem}
      onOpenChange={(open) => {
        if (!open) {
          props.onDismiss();
        }
      }}
    >
      <Popover.Anchor asChild>
        <imagebutton
          BackgroundColor3={Color3.fromRGB(30, 33, 41)}
          Image="rbxassetid://1234567890"
          Size={UDim2.fromOffset(48, 48)}
        />
      </Popover.Anchor>

      <Popover.Portal>
        <Popover.Content placement="top" sideOffset={10}>
          <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(200, 72)}>
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(180, 56)}
              Text="New item! Open your inventory to equip it."
              TextColor3={Color3.fromRGB(236, 241, 249)}
              TextWrapped={true}
            />
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Placement and offsets

Positioning is tuned entirely on `Popover.Content`. `placement` requests a side (default `"bottom"`), `sideOffset` adds a gap between the anchor and the surface, `alignOffset` shifts the surface along the anchor's cross axis, and `collisionPadding` sets the minimum distance kept from the screen edge (default `8`). When the requested side does not fit, the content flips to the opposite side — and its entrance motion animates from whichever side actually resolved.

```tsx title="StatTooltipPopover.tsx"
import { Popover } from "@lattice-ui/react-popover";

export function StatTooltipPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <textbutton Text="Attack: 42" Size={UDim2.fromOffset(120, 32)} />
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content placement="right" sideOffset={8} alignOffset={-4} collisionPadding={16}>
          <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(180, 64)}>
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(160, 48)}
              Text="Base 30 + weapon 12. Scales with strength."
              TextColor3={Color3.fromRGB(236, 241, 249)}
              TextWrapped={true}
            />
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Closing from inside

`Popover.Close` closes the popover from anywhere inside the content; with `asChild` it merges the close behavior onto your own button. On close, the focus scope restores gamepad selection to whatever was selected before the popover opened — the trigger focuses itself as it opens, so selection lands back on the trigger without any wiring on your side.

```tsx title="LoadoutSavePopover.tsx"
import { Popover } from "@lattice-ui/react-popover";

export function LoadoutSavePopover(props: { onSave: () => void }) {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <textbutton Text="Save loadout" Size={UDim2.fromOffset(140, 38)} />
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content placement="bottom" sideOffset={6}>
          <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(220, 110)}>
            <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(200, 28)}
              Text="Overwrite slot 1?"
              TextColor3={Color3.fromRGB(236, 241, 249)}
            />
            <Popover.Close asChild>
              <textbutton
                BackgroundColor3={Color3.fromRGB(88, 142, 255)}
                Event={{ Activated: props.onSave }}
                Size={UDim2.fromOffset(100, 32)}
                Text="Save"
                TextColor3={Color3.fromRGB(240, 244, 252)}
              />
            </Popover.Close>
            <Popover.Close asChild>
              <textbutton Size={UDim2.fromOffset(100, 32)} Text="Cancel" />
            </Popover.Close>
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

### Custom exit motion

`Popover.Content` runs no motion of its own. Pass a `transition` to animate it, and `forceMount` when the node should stay mounted instead of unmounting after the exit finishes — useful when you drive motion yourself or need the instance to persist. The wrapper is a `frame` with or without `asChild`, so `createPopperEntranceRecipe` is the recipe that matches it.

```tsx title="SlowRevealPopover.tsx"
import { Popover } from "@lattice-ui/react-popover";
import { createPopperEntranceRecipe } from "@lattice-ui/react-motion";

const SLOW_REVEAL = createPopperEntranceRecipe("top", 16, 0.25);

export function SlowRevealPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <textbutton Text="Match summary" Size={UDim2.fromOffset(140, 38)} />
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content placement="top" sideOffset={10} transition={SLOW_REVEAL} forceMount>
          <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(240, 120)}>
            <textlabel
              BackgroundTransparency={1}
              Size={UDim2.fromOffset(220, 100)}
              Text="Victory — 12 eliminations"
              TextColor3={Color3.fromRGB(236, 241, 249)}
            />
          </frame>
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}
```

## How it behaves

### Open state

`Popover.Root` is controllable. Pass `open` and `onOpenChange` to control it, or `defaultOpen` to run uncontrolled (defaults to closed). `Popover.Trigger` toggles the open state on activation and `Popover.Close` closes it; outside-press dismissal goes through the same `setOpen` path. Everything funnels into one state, so controlled and uncontrolled usage behave identically.

### Trigger and anchor resolution

The trigger registers itself as the positioning anchor, but only while no `Popover.Anchor` has claimed the slot — a mounted anchor always takes precedence, whether it mounts before or after the trigger. The default trigger is a 150x38 `textbutton` labeled "Toggle Popover"; with `asChild` its `Active`, `Activated` handler, `Selectable={false}`, and ref are merged onto your single child element. `disabled` blocks toggling and removes the trigger from focus tracking. `Popover.Anchor` renders a zero-size transparent frame by default; with `asChild` it tracks your element's geometry directly and renders nothing extra.

### Positioning

`Popover.Content` is positioned by the popper foundation. It measures the anchor and the content, then resolves a final placement, flipping to the opposite side when the requested side would collide with the screen edge. Tune it with `placement` (`"top" | "bottom" | "left" | "right"`, default `"bottom"`), `sideOffset` (gap from the anchor, default `0`), `alignOffset` (shift along the anchor's cross axis, default `0`), and `collisionPadding` (minimum distance from the screen edge, default `8`).

Until the first measurement resolves, the surface is parked far offscreen and the reveal motion is held back — the content never flashes at an unpositioned location, and the entrance always animates from the placement that actually resolved, not the one you requested.

### Focus and selection

`Popover.Content` mounts a focus scope tied to the open state. Because Popover is non-modal by default, the scope is **not trapped** — gamepad and `GuiObject` selection can move freely between the popover and the rest of the screen. The trigger focuses itself just before opening, and the scope restores focus to the previously selected object on close, so selection returns to the trigger without extra wiring. Setting `modal` on the `Root` switches the scope to trapped, keeping selection inside the surface while it is open.

### Dismissal

`Popover.Content` participates in dismissable-layer behavior while open. An outside press dismisses it, and when `modal` is `true`, interaction behind the surface is also blocked. Use `onPointerDownOutside` (pointer presses) and `onInteractOutside` (any other outside interaction) to observe or veto those interactions before the popover closes.

### Motion and presence

`Popover.Content` runs no motion unless you pass a `transition`. `createPopperEntranceRecipe(placement)` from `@lattice-ui/react-motion` slides the surface 10 pixels in from the resolved placement side while fading `BackgroundTransparency`, on the default wrapper and under `asChild` alike. It takes its own distance and duration, and exits at 0.8x the reveal.

Without `forceMount`, the content mounts when the popover opens and unmounts after the exit motion completes. Pass `forceMount` to skip the presence wrapper entirely: the node stays mounted while closed with its `Visible` driven by the motion controller, which is useful when you drive motion yourself or need the instance to persist across open cycles.

> **Modal is opt-in**
>
> Popover defaults to `modal={false}`: the surface floats over the UI without blocking it and without trapping selection. Set `modal` on `Popover.Root` only when the popover should behave like a focused, blocking surface — at which point it traps focus and blocks interaction behind it, much like Dialog.

> **The anchor wins over the trigger**
>
> When both a `Popover.Trigger` and a `Popover.Anchor` are mounted, the content always positions against the anchor. There is no prop to flip this — remove the anchor if the trigger should be the reference again.

> **A fade reaches the surface, not its children**
>
> `BackgroundTransparency` fades one instance's own background, so the surface fades while the labels and icons inside it stay opaque. Fade those with them (`TextTransparency`, `ImageTransparency`), or pass `asChild` with your own `canvasgroup` and `createCanvasGroupPopperEntranceRecipe`, which fades the whole subtree as one composited layer.

## API reference

### Popover.Root

| Prop | Type | Description |
| --- | --- | --- |
| `open` | `boolean` | Controlled open state. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. |
| `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes — from the trigger, a close button, outside dismissal, or a controlled update. |
| `modal` | `boolean` | When true, blocks interaction behind the popover and traps focus inside it. Defaults to false. |
| `children` | `React.ReactNode` | The popover parts. |

### Popover.Trigger

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge toggle behavior, focus tracking, and the anchor ref onto the single child element instead of the textbutton the part renders. |
| `disabled` | `boolean` | Prevents the trigger from toggling the popover and removes it from focus tracking. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### Popover.Anchor

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Track the single child element's geometry instead of the frame the part renders. A mounted anchor takes positioning precedence over the trigger. |
| `children` | `React.ReactElement` | The element to anchor against. Required when asChild is set. |

### Popover.Portal

| Prop | Type | Description |
| --- | --- | --- |
| `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. |
| `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. |
| `children` | `React.ReactNode` | The content part. |

### Popover.Content

| Prop | Type | Description |
| --- | --- | --- |
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on. Flips to the opposite side on collision. Defaults to "bottom". |
| `sideOffset` | `number` | Gap in pixels between the anchor and the content. Defaults to 0. |
| `alignOffset` | `number` | Shift in pixels along the anchor's cross axis. Defaults to 0. |
| `collisionPadding` | `number` | Minimum distance in pixels to keep from the screen edge. Defaults to 8. |
| `asChild` | `boolean` | Position and animate the single child element instead of the auto-sized frame wrapper the part renders. createPopperEntranceRecipe fits either path; supply a canvasgroup here if you want the whole subtree to fade as one layer. |
| `forceMount` | `boolean` | Keeps the content mounted while closed and through exit motion, with Visible driven by the motion controller, instead of unmounting after exit. |
| `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, on the default path and under asChild alike. |
| `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. |
| `children` | `React.ReactNode` | The surface contents. Must be a single valid element when asChild is set. |

### Popover.Close

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge close behavior onto the single child element instead of the textbutton the part renders. Event handlers compose, so the child's own Activated handler still runs. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

## 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)
- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
