# Popper

> Placement math, layout observers, and a positioning hook for anchoring a content surface to a GuiObject with viewport collision handling.

Source: https://docs.astra-void.xyz/lattice-ui/reference/popper/

`@lattice-ui/react-popper` · Experimental · depends on `runtime`, `motion`

Popper is the positioning foundation for anchored surfaces — the placement engine behind Popover, Tooltip, Menu, and Select listboxes. Given an anchor `GuiObject` and a content `GuiObject`, it computes where the content should sit, falls back to a better side when the requested one would overflow, and keeps the result in sync as the anchor, content, or viewport changes.

It works entirely in screen space using `AbsolutePosition` and `AbsoluteSize`, so it does not care how the surface is composed. It only returns a `UDim2` position and an `AnchorPoint` for you to apply to the content node.

> **Experimental**
>
> This package is experimental. The placement-relative offsets and viewport collision handling are in active development, and the API — option names, defaults, and the shape of the computed result — may change in a future release.

## Import

```ts
import { computePopper, usePopper } from "@lattice-ui/react-popper";
import { subscribeAnchor, subscribeContent, subscribeViewport } from "@lattice-ui/react-popper";
```

## API reference

### usePopper

The primary entry point. Pass refs to the anchor and content `GuiObject`s and it tracks their layout, recomputes positioning on change, and returns a position and anchor point to apply to the content.

```ts
function usePopper(options: UsePopperOptions): UsePopperResult;

const { position, anchorPoint, placement, isPositioned } = usePopper({
  anchorRef,
  contentRef,
  placement: "bottom",
  sideOffset: 8,
});

return (
  <frame
    ref={contentRef}
    AnchorPoint={anchorPoint}
    Position={position}
    Visible={isPositioned}
  />
);
```

`usePopper` reads both refs, measures the anchor's `AbsolutePosition`/`AbsoluteSize` and the content's `AbsoluteSize`, and runs `computePopper`. It subscribes to anchor layout changes, content size changes, and viewport resizes through the observers below, and reconciles the observed instances every `Heartbeat` so the correct nodes stay tracked even as refs swap. Updates run inside a layout effect, so positioning is applied before paint.

Viewport bounds come from the nearest `ScreenGui` ancestor of the content (honoring the topbar inset unless `IgnoreGuiInset` is set), falling back to the current camera's `ViewportSize`. Until the content has a measured size, `isPositioned` is `false` — gate the surface's `Visible` on it to avoid a one-frame flash at the origin.

| Prop | Type | Description |
| --- | --- | --- |
| `anchorRef` | `RefObject<GuiObject> \| MutableRefObject<GuiObject \| undefined>` | Ref to the element the surface anchors to. Its AbsolutePosition and AbsoluteSize drive placement. |
| `contentRef` | `RefObject<GuiObject> \| MutableRefObject<GuiObject \| undefined>` | Ref to the surface being positioned. Its AbsoluteSize is measured for collision math. |
| `placement` | `PopperPlacement` | Preferred side: "top", "bottom", "left", or "right". Defaults to "bottom". |
| `sideOffset` | `number` | Gap between anchor and content along the placement axis. Defaults to 0. |
| `alignOffset` | `number` | Shift along the cross axis. Defaults to 0. |
| `collisionPadding` | `number` | Minimum distance kept between the content and the viewport edges. Defaults to 8. |
| `enabled` | `boolean` | When false, skips computation and detaches observers. Defaults to true. |

The returned `UsePopperResult` extends `ComputePopperResult` with the live measurement and an imperative escape hatch:

| Prop | Type | Description |
| --- | --- | --- |
| `position` | `UDim2` | Offset-based position to apply to the content's Position. |
| `anchorPoint` | `Vector2` | AnchorPoint to apply to the content, derived from the resolved placement. |
| `placement` | `PopperPlacement` | The placement actually used after collision resolution, which may differ from the requested one. |
| `contentSize` | `Vector2` | Last measured AbsoluteSize of the content. Zero until first measured. |
| `isPositioned` | `boolean` | True once the content has a non-zero measured size and a real position has been computed. |
| `update` | `() => void` | Forces an immediate recompute. Rarely needed — observers cover normal changes. |

### computePopper

The pure placement function behind the hook. It takes fully measured geometry and returns a position, anchor point, and resolved placement. No Roblox instances, refs, or side effects, which makes it useful for testing or driving positioning yourself.

```ts
function computePopper(input: ComputePopperInput): ComputePopperResult;

const { position, anchorPoint, placement } = computePopper({
  anchorPosition: anchor.AbsolutePosition,
  anchorSize: anchor.AbsoluteSize,
  contentSize: content.AbsoluteSize,
  viewportRect: new Rect(new Vector2(0, 0), camera.ViewportSize),
  placement: "bottom",
  sideOffset: 8,
});
```

It evaluates the requested placement, then its opposite, then the two orthogonal sides, scoring each candidate by how far it overflows the viewport plus a small penalty for falling back to an orthogonal side. The first zero-overflow candidate wins immediately; otherwise the least-bad candidate is chosen and clamped inside the viewport with `collisionPadding`. The returned `position` is an offset `UDim2` already adjusted for the resolved `anchorPoint`.

`ComputePopperInput` merges the positioning options (`placement`, `sideOffset`, `alignOffset`, `collisionPadding`) with the required geometry:

| Prop | Type | Description |
| --- | --- | --- |
| `anchorPosition` | `Vector2` | The anchor's top-left in screen space (AbsolutePosition). |
| `anchorSize` | `Vector2` | The anchor's AbsoluteSize. |
| `contentSize` | `Vector2` | The content's AbsoluteSize. May be zero before the surface is measured. |
| `viewportRect` | `Rect` | The bounds to keep the content inside, in screen space. |

### subscribeAnchor

```ts
function subscribeAnchor(anchor: GuiObject, onChange: () => void): ObserverUnsubscribe;
```

Calls `onChange` whenever the anchor's `AbsolutePosition` or `AbsoluteSize` changes. Returns an unsubscribe function that disconnects both signals.

### subscribeContent

```ts
function subscribeContent(content: GuiObject, onChange: () => void): ObserverUnsubscribe;
```

Calls `onChange` when the content's `AbsoluteSize` changes. It deliberately ignores position changes: the positioned ancestor owns placement, and relative content movement may be motion-owned, so it should not invalidate the computed result.

### subscribeViewport

```ts
function subscribeViewport(anchor: GuiObject | undefined, onChange: () => void): ObserverUnsubscribe;
```

Watches whatever defines the viewport bounds. If the anchor lives under a `ScreenGui`, it tracks that `ScreenGui`'s `AbsoluteSize`; otherwise it falls back to the current camera's `ViewportSize`, re-binding automatically when `Workspace.CurrentCamera` changes. Returns an unsubscribe function.

### Types

The package re-exports the types backing the API above:

| Prop | Type | Description |
| --- | --- | --- |
| `PopperPlacement` | `"top" \| "bottom" \| "left" \| "right"` | The four sides a surface can attach to. |
| `PopperPositioningOptions` | `object` | Optional placement, sideOffset, alignOffset, and collisionPadding. |
| `NormalizedPopperPositioningOptions` | `object` | The same options with every field resolved to a concrete value. |
| `ComputePopperInput` | `object` | Positioning options plus the measured geometry computePopper needs. |
| `ComputePopperResult` | `object` | The computed position, anchorPoint, and resolved placement. |
| `UsePopperOptions` | `object` | Positioning options plus anchorRef, contentRef, and enabled. |
| `UsePopperResult` | `object` | ComputePopperResult plus contentSize, isPositioned, and update. |
| `ObserverUnsubscribe` | `() => void` | The disconnect callback returned by every subscribe helper. |

> **Roblox bounds and the topbar inset**
>
> Viewport bounds come from the nearest `ScreenGui` ancestor of the content, not the raw camera viewport. When that `ScreenGui` does not set `IgnoreGuiInset`, the topbar inset from `GuiService:GetGuiInset()` is folded into the bounds so surfaces are not clamped under the topbar. Render anchored surfaces inside a `ScreenGui` for accurate collision handling.

## Related links

- [Popover](https://docs.astra-void.xyz/lattice-ui/components/popover.md)
- [Tooltip](https://docs.astra-void.xyz/lattice-ui/components/tooltip.md)
- [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
