Latticeguides

Roblox UI constraints

The Roblox-specific realities — GuiObject selection, BasePlayerGui portals, ScreenGui ordering and inset, default selection limits — that shape every lattice-ui primitive.

If you come from the web, most UI-library instincts transfer to Roblox — but a handful do not, and those are exactly the ones that make overlays, menus, and modals tricky. There is no DOM, no document-level z-index, no tab order, and no event that says “the user clicked outside this element.” Instead there is a single selected GuiObject, a PlayerGui you portal into, ScreenGui layers ordered by a number, and a default gamepad selection engine that was never designed for layered surfaces.

lattice-ui exists largely to paper over these constraints so you do not re-solve them per screen. This guide collects the Roblox realities the primitives are built around, so that when you compose or extend them, the behavior makes sense.

Selection is a single GuiObject

Gamepad and keyboard navigation on Roblox is selection: GuiService.SelectedObject holds at most one GuiObject. There is no focus tree and no built-in “previously focused” concept. A GuiObject is only selectable when its Selectable property is true, it is Visible, and its whole ancestor chain is visible and (for the LayerCollector) enabled.

This is why lattice-ui ships @lattice-ui/react-focus: a single mutable property cannot, on its own, express “trap selection inside this dialog” or “restore selection to the trigger that opened it.” The focus manager keeps a model of scopes and nodes, decides what should be selected, and writes it to GuiService.SelectedObject — reading it back when the player moves selection so the two stay in agreement.

Keep Selectable in lockstep with disabled state
<textbutton
Active={!disabled}
Selectable={!disabled} // the only thing Roblox actually enforces
AutoButtonColor={false}
Text={label}
/>

Portals target BasePlayerGui

A surface that must visually escape its parent — a dialog, a popover anchored to a deeply nested button — cannot just render in place; a parent’s clipping or ZIndex would trap it. On Roblox the destination for top-level UI is the player’s BasePlayerGui (the PlayerGui), and @lattice-ui/react-layer’s Portal renders there via ReactRoblox.createPortal.

You set the container once at the root with PortalProvider, and every Portal (and every primitive that portals, like Dialog.Portal) inherits it:

UIRoot.tsx
import { Players } from "@rbxts/services";
import { PortalProvider } from "@lattice-ui/react-layer";
export function UIRoot(props: { children: React.ReactNode }) {
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui") as PlayerGui;
return (
<PortalProvider container={playerGui} displayOrderBase={1000}>
{props.children}
</PortalProvider>
);
}
PropTypeDescription
containerBasePlayerGuiWhere portalled surfaces mount. Usually the LocalPlayer's PlayerGui.
displayOrderBasenumberBase DisplayOrder for layered ScreenGuis. Defaults to 1000.
childrenReact.ReactNodeYour app tree.

ScreenGui ordering and inset

Two ScreenGui properties decide how a layered surface sits on screen.

DisplayOrder is Roblox’s only cross-ScreenGui stacking control — higher numbers render on top. Within a single ScreenGui, ZIndex orders children, but across separate ScreenGuis only DisplayOrder matters. lattice-ui derives layer order from a base (displayOrderBase, default 1000) so dialogs, popovers, and toasts stack in a predictable, app-controlled band rather than colliding with your game HUD.

IgnoreGuiInset controls whether the surface starts below the top-bar inset or covers the full viewport. Full-screen overlays and backdrops want to ignore the inset so they truly cover everything; lattice-ui’s layer defaults reflect this (DEFAULT_LAYER_IGNORE_GUI_INSET is true).

A full-screen backdrop covers the inset
// Backdrops should fill the whole viewport, top-bar included.
<screengui IgnoreGuiInset={true} DisplayOrder={1000}>
<frame
BackgroundColor3={Color3.fromRGB(0, 0, 0)}
BackgroundTransparency={0.5}
Size={UDim2.fromScale(1, 1)}
/>
</screengui>

Default selection is not enough

Roblox’s built-in gamepad selection picks the “nearest” selectable object by on-screen geometry. That is fine for a static menu, but it breaks down for the interactions lattice-ui composes:

  • It does not understand modality — nothing stops the cursor from selecting a button behind an open dialog.
  • It has no concept of restore — close a menu and selection does not return to the item that opened it.
  • It cannot express ordered, wrapping movement that skips disabled or hidden items in a known sequence.
  • It has no layer awareness for nested overlays (a popover inside a dialog).

lattice-ui replaces this with explicit models: focus scopes (trap and restore), focus nodes (what may be selected), and ordered-selection helpers (deterministic next/previous). When you build a custom composite, lean on those rather than the default engine.

Move selection deterministically instead of by geometry
import {
getCurrentOrderedSelectionEntry,
getRelativeOrderedSelectionEntry,
focusOrderedSelectionEntry,
} from "@lattice-ui/react-focus";
function focusNext(entries: Array<OrderedSelectionEntry>) {
const current = getCurrentOrderedSelectionEntry(entries);
const next = getRelativeOrderedSelectionEntry(entries, current?.id, 1);
focusOrderedSelectionEntry(next); // skips disabled/hidden, clamps at the end
}

Stable GuiObject refs

The focus model, motion hosts, and dismissable layers all key off the actual GuiObject instance behind a ref. If that instance is swapped between renders — a different branch of a ternary, a remounted subtree — the manager sees the old node disappear and the new one as unrelated, which drops trap targets and restore snapshots.

Keep the host instance stable across renders: toggle Visible, Selectable, or color instead of conditionally rendering a different element for triggers, anchors, and content roots. Reserve mount/unmount for genuine presence changes — and when you do unmount an animating surface, let presence motion hold it until the exit completes.