@lattice-ui/popper Experimental
depends on core , 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.
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
import { computePopper, usePopper } from "@lattice-ui/popper";import { subscribeAnchor, subscribeContent, subscribeViewport } from "@lattice-ui/popper";API reference
usePopper
The primary entry point. Pass refs to the anchor and content GuiObjects and it tracks their layout, recomputes positioning on change, and returns a position and anchor point to apply to the content.
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.
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
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
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
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. |
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.