If you come from the web, most UI-library instincts transfer to Roblox. 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.” What you get
instead 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. It decides what should be selected and
writes it to GuiService.SelectedObject. It reads the property back when the player moves
selection, so the two stay in agreement.
<textbutton Active={!disabled} Selectable={!disabled} // the only thing Roblox actually enforces AutoButtonColor={false} Text={label}/>Setting only Active={false} still leaves the button selectable by gamepad. Whenever something is disabled, set Selectable={false} too — the focus manager’s getDisabled mirrors intent in the model, but Roblox’s selection engine obeys Selectable. If they disagree, the player can land on a control your logic treats as off.
Portals target BasePlayerGui
A surface that must visually escape its parent cannot just render in place. That covers a dialog, or
a popover anchored to a deeply nested button. 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:
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> );}| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Where portalled surfaces mount. Usually the LocalPlayer's PlayerGui. |
| displayOrderBase | number | Base DisplayOrder for layered ScreenGuis. Defaults to 1000. |
| children | React.ReactNode | Your app tree. |
StarterGui is copied into PlayerGui after the player loads, so reading it too early yields nil. Use WaitForChild("PlayerGui") (or resolve it from a player-ready signal) before handing it to PortalProvider. The provider uses a strict context — primitives that portal will error if no provider is mounted above them.
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, defaulting
to 1000. 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.
// 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>Pick a displayOrderBase that sits above your game’s own HUD ScreenGuis. If your HUD uses DisplayOrder in the hundreds, the default base of 1000 keeps lattice-ui surfaces on top. Mixing ZIndex and DisplayOrder to fight stacking is a sign two surfaces should be in different ScreenGuis with explicit DisplayOrders instead.
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.
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, by a different branch of a ternary or a
remounted subtree, the manager sees the old node disappear and the new one as unrelated. That 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.
A trigger, an anchor, and a content boundary should each be a single, stable GuiObject for its lifetime. Re-creating it every render is the most common cause of “selection jumps to the wrong place” and “the dialog won’t restore focus” bugs.