Lattice reference

Popper

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

@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.

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.