@lattice-ui/react-popperExperimentaldepends on runtime, motionPopper is 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. It 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 in screen space through AbsolutePosition and AbsoluteSize, so it does not care how the
surface is composed. It returns a UDim2 position and an AnchorPoint to apply to the content
node.
This package is experimental. The placement-relative offsets and collision handling are in active development, and option names, defaults and the shape of the computed result may change.
Import
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 GuiObjects and it tracks their
layout, recomputes on change, and returns a position and anchor point.
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} />);It measures the anchor’s AbsolutePosition/AbsoluteSize and the content’s AbsoluteSize, then
runs computePopper. It subscribes to anchor layout, content size and viewport resizes through the
observers below. It reconciles the observed instances every Heartbeat, so the right nodes stay
tracked as refs swap. Updates run in a layout effect, so positioning applies before paint.
Viewport bounds come from the content’s nearest ScreenGui ancestor, honouring the topbar inset
unless IgnoreGuiInset is set, falling back to the 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: measured geometry in, a position, anchor point and resolved placement out. No Roblox instances, refs or side effects.
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
by viewport overflow plus a small penalty for an orthogonal fallback. The first zero-overflow
candidate wins. Otherwise the least-bad one is 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 when the anchor’s AbsolutePosition or AbsoluteSize changes, returning an
unsubscribe that disconnects both signals.
subscribeContent
function subscribeContent(content: GuiObject, onChange: () => void): ObserverUnsubscribe;Calls onChange when the content’s AbsoluteSize changes. Position changes are ignored
deliberately — the positioned ancestor owns placement, and relative content movement may be
motion-owned.
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.