Latticereference

Popper

Placement maths, layout observers, and the hook that anchors a surface to a `GuiObject`.

@lattice-ui/react-popperExperimentaldepends on runtime, motion

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

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.

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

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

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.