Latticereference

Popper

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

@lattice-ui/react-popperExperimentaldepends 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.

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

PropTypeDescription
anchorRefRefObject<GuiObject> | MutableRefObject<GuiObject | undefined>Ref to the element the surface anchors to. Its AbsolutePosition and AbsoluteSize drive placement.
contentRefRefObject<GuiObject> | MutableRefObject<GuiObject | undefined>Ref to the surface being positioned. Its AbsoluteSize is measured for collision math.
placementPopperPlacementPreferred side: "top", "bottom", "left", or "right". Defaults to "bottom".
sideOffsetnumberGap between anchor and content along the placement axis. Defaults to 0.
alignOffsetnumberShift along the cross axis. Defaults to 0.
collisionPaddingnumberMinimum distance kept between the content and the viewport edges. Defaults to 8.
enabledbooleanWhen false, skips computation and detaches observers. Defaults to true.

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

PropTypeDescription
positionUDim2Offset-based position to apply to the content's Position.
anchorPointVector2AnchorPoint to apply to the content, derived from the resolved placement.
placementPopperPlacementThe placement actually used after collision resolution, which may differ from the requested one.
contentSizeVector2Last measured AbsoluteSize of the content. Zero until first measured.
isPositionedbooleanTrue once the content has a non-zero measured size and a real position has been computed.
update() => voidForces 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:

PropTypeDescription
anchorPositionVector2The anchor's top-left in screen space (AbsolutePosition).
anchorSizeVector2The anchor's AbsoluteSize.
contentSizeVector2The content's AbsoluteSize. May be zero before the surface is measured.
viewportRectRectThe 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:

PropTypeDescription
PopperPlacement"top" | "bottom" | "left" | "right"The four sides a surface can attach to.
PopperPositioningOptionsobjectOptional placement, sideOffset, alignOffset, and collisionPadding.
NormalizedPopperPositioningOptionsobjectThe same options with every field resolved to a concrete value.
ComputePopperInputobjectPositioning options plus the measured geometry computePopper needs.
ComputePopperResultobjectThe computed position, anchorPoint, and resolved placement.
UsePopperOptionsobjectPositioning options plus anchorRef, contentRef, and enabled.
UsePopperResultobjectComputePopperResult plus contentSize, isPositioned, and update.
ObserverUnsubscribe() => voidThe disconnect callback returned by every subscribe helper.