Latticeguides

Positioning with Popper

Anchor floating surfaces with a shared placement model and collision-aware flipping.

Anchored surfaces all face the same problem: popovers, menus, selects, tooltips and comboboxes. Place a floating panel next to an anchor so it stays on the requested side, lines up as asked, and never spills off the viewport.

@lattice-ui/react-popper is the positioning engine shared by every anchored primitive. Give it an anchor ref, a content ref and a desired placement. It measures both GuiObjects, picks the best side that fits, and hands back a Position, an AnchorPoint and the resolved placement.

Most of the time you set placement, sideOffset, alignOffset and collisionPadding on a component like Popover.Content and the primitive forwards them. This guide explains what those props mean.

Install

Install Popper directly only for a custom anchored primitive — the shipped components already depend on it.

pnpm add @lattice-ui/react-popper

The positioning model

Popper describes a position with four inputs:

  • placement — which side of the anchor the content prefers: "top", "bottom", "left", or "right". Defaults to "bottom".
  • sideOffset — distance in pixels pushing the content away from the anchor along the placement axis. This is the visible gap between anchor and panel.
  • alignOffset — distance in pixels shifting the content along the anchor’s edge, for nudging the panel left/right (or up/down) relative to centered.
  • collisionPadding — minimum gap in pixels to keep between the content and the viewport edges. Defaults to 8.
Positioning options
type PopperPositioningOptions = {
placement?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
alignOffset?: number;
collisionPadding?: number;
};

Popper centres the content against the anchor on the cross axis, then applies your offsets. The result is a UDim2 offset position plus the Vector2 AnchorPoint that makes it visually correct — (0.5, 0) for "bottom", (0, 0.5) for "right".

Collision-aware flipping

The requested side is a preference. Popper evaluates it first, then its opposite, then the two orthogonal sides, scoring each by how far it would overflow the viewport including collisionPadding. The least-overflowing candidate wins, a perfect fit short-circuits the search, and the chosen side comes back as placement.

The chosen side can differ from the side you asked for. Drive your entrance motion and any visual arrow from the resolved placement, not the requested one:

Reading the resolved placement
const popper = usePopper({ anchorRef, contentRef, placement: "bottom" });
// popper.placement may be "top" if "bottom" would overflow the screen.
const resolvedPlacement = popper.isPositioned ? popper.placement : "bottom";

If nothing fits, Popper clamps the least-bad candidate inside the viewport (respecting collisionPadding) rather than letting the panel run off-screen.

Using it through a component

In practice you set these props on the component’s content part. Popover is representative. Menu, select, tooltip and combobox expose the same names.

GuildBadgePopover.tsx
import { Popover } from "@lattice-ui/react-popover";
export function GuildBadgePopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<imagebutton Image="rbxassetid://0" Size={UDim2.fromOffset(48, 48)} />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
placement="top"
sideOffset={10}
alignOffset={0}
collisionPadding={12}
>
<frame
BackgroundColor3={Color3.fromRGB(24, 26, 32)}
Size={UDim2.fromOffset(220, 120)}
>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromScale(1, 1)}
Text="Member since 2021"
TextColor3={Color3.fromRGB(240, 244, 250)}
/>
</frame>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

The component owns the anchor and content refs internally — Popover.Trigger registers as the anchor unless you override it with Popover.Anchor. You only choose the geometry.

Using usePopper directly

When you build your own anchored surface, call the usePopper hook. Give it stable refs for the anchor and the content and apply the result to your floating frame.

usePopper in a custom surface
import React from "@rbxts/react";
import { usePopper } from "@lattice-ui/react-popper";
function FloatingCard(props: { anchorRef: React.RefObject<GuiObject>; open: boolean }) {
const contentRef = React.useRef<GuiObject>();
const popper = usePopper({
anchorRef: props.anchorRef,
contentRef,
placement: "bottom",
sideOffset: 8,
collisionPadding: 12,
enabled: props.open,
});
if (!props.open) {
return undefined;
}
return (
<frame
AnchorPoint={popper.anchorPoint}
Position={popper.position}
Size={UDim2.fromOffset(240, 160)}
Visible={popper.isPositioned}
ref={contentRef}
/>
);
}

usePopper measures both nodes, recomputes on layout or viewport changes, and exposes:

PropTypeDescription
positionUDim2Offset position to apply to the content frame.
anchorPointVector2AnchorPoint that makes position render on the resolved side.
placementPopperPlacementThe side actually chosen after collision handling.
contentSizeVector2Measured AbsoluteSize of the content node.
isPositionedbooleanTrue once the content has been measured and placed. Use it to gate visibility.
update() => voidForces an immediate recompute.

How measurement works

Popper reads AbsolutePosition and AbsoluteSize from the anchor and content, then derives placement against the viewport. Two Roblox specifics matter:

  • Viewport bounds come from the nearest ScreenGui, not the camera. Popper walks up from the content to find its ScreenGui ancestor and uses that AbsoluteSize (adjusted for GetGuiInset when the gui respects the inset). This keeps positioning correct for portaled surfaces and Studio plugin guis, not just full-screen ScreenGuis. If no ScreenGui is found it falls back to the current camera’s ViewportSize.
  • It re-measures reactively. Popper subscribes to the anchor’s AbsolutePosition/AbsoluteSize, the content’s AbsoluteSize and the viewport’s size, recomputing whenever any of them change. It tracks layout shifts, automatic sizing and resolution changes without polling.

When to use it

  • You are building a custom anchored surface that should track a trigger and stay on screen.
  • You need collision-aware flipping without writing edge-detection math yourself.

When not to use it

  • The surface is full-screen or centered (a modal dialog, a toast region). Position it directly with AnchorPoint and scale — there is no anchor to track.
  • You want the panel pinned to a fixed screen corner regardless of any trigger. Use a plain UDim2 position.
  • The content is inline in the normal layout flow. Popper is for floating surfaces that escape their parent.