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-poppernpm install @lattice-ui/react-popperyarn add @lattice-ui/react-popperbun add @lattice-ui/react-popperThe 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 to8.
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:
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.
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.
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:
| Prop | Type | Description |
|---|---|---|
| position | UDim2 | Offset position to apply to the content frame. |
| anchorPoint | Vector2 | AnchorPoint that makes position render on the resolved side. |
| placement | PopperPlacement | The side actually chosen after collision handling. |
| contentSize | Vector2 | Measured AbsoluteSize of the content node. |
| isPositioned | boolean | True once the content has been measured and placed. Use it to gate visibility. |
| update | () => void | Forces an immediate recompute. |
On the first frame the content has not been measured, so isPositioned is false and the position is a default. Gate the surface with Visible={popper.isPositioned} (or render it off-screen) to avoid a one-frame flash at the wrong spot. Lattice’s own components hide the panel at (-9999, -9999) until positioning settles.
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 itsScreenGuiancestor and uses thatAbsoluteSize(adjusted forGetGuiInsetwhen the gui respects the inset). This keeps positioning correct for portaled surfaces and Studio plugin guis, not just full-screenScreenGuis. If noScreenGuiis found it falls back to the current camera’sViewportSize. - It re-measures reactively. Popper subscribes to the anchor’s
AbsolutePosition/AbsoluteSize, the content’sAbsoluteSizeand the viewport’s size, recomputing whenever any of them change. It tracks layout shifts, automatic sizing and resolution changes without polling.
anchorRef and contentRef must resolve to real GuiObjects before Popper can measure anything. If either ref is empty Popper reports isPositioned: false and waits. When the anchor lives behind asChild, make sure the host element actually forwards its ref to the trigger.
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
AnchorPointand scale — there is no anchor to track. - You want the panel pinned to a fixed screen corner regardless of any trigger. Use a plain
UDim2position. - The content is inline in the normal layout flow. Popper is for floating surfaces that escape their parent.