Latticeguides

Focus management

How lattice-ui keeps GuiObject selection predictable across overlays — focus scopes, focus nodes, restore snapshots, and ordered selection.

On Roblox, “focus” is gamepad and keyboard selection: a single GuiObject held by GuiService.SelectedObject. There is no focus tree and no tab order. The moment you layer surfaces, that one property becomes contended. Open a modal and selection should move into it. Close it and selection should land back on whatever opened it. Meanwhile the gamepad should not wander out.

@lattice-ui/react-focus makes that deterministic. It keeps its own model of focus scopes and focus nodes, decides which node should be selected, and bridges that decision to GuiService.SelectedObject. Every layered primitive sits on top of it.

You rarely call it directly — Dialog and friends wire it for you. Reach for it when building your own selectable surface, or driving selection by hand.

Import

import {
FocusScope,
useFocusNode,
focusGuiObject,
focusNode,
getFocusedGuiObject,
captureRestoreSnapshot,
restoreSnapshot,
} from "@lattice-ui/react-focus";

The model: scopes and nodes

Two registrations drive everything.

  • A focus node is a selectable GuiObject the manager may select, registered with useFocusNode. The manager only selects a registered node, or an implicit one it infers when selection arrives from outside.
  • A focus scope is a region with rules about activity, trapping and restoration. A dialog body, a menu list. FocusScope registers one and shares its id through context, so any useFocusNode inside belongs to it.

The manager resolves which node is focusable and writes it to GuiService.SelectedObject. When the player moves the gamepad, it reads the change back and re-resolves. That two-way bridge is the point — your model and Roblox’s selection stay in agreement.

Registering a focusable node

useFocusNode takes a ref to the GuiObject and registers it under the nearest scope.

SelectableTile.tsx
import { React } from "@rbxts/react";
import { useFocusNode } from "@lattice-ui/react-focus";
export function SelectableTile(props: { label: string; disabled?: boolean }) {
const tileRef = React.useRef<TextButton>();
useFocusNode({
ref: tileRef,
getDisabled: () => props.disabled === true,
});
return (
<textbutton
ref={tileRef}
Active={!props.disabled}
Selectable={!props.disabled}
Size={UDim2.fromOffset(160, 40)}
Text={props.label}
/>
);
}

Defining a scope

Wrap a region in FocusScope to make it a focus boundary. By default a scope is active, does not trap, and does restore focus on deactivation.

Panel.tsx
import { FocusScope } from "@lattice-ui/react-focus";
export function Panel(props: { open: boolean; children: React.ReactNode }) {
return (
<FocusScope active={props.open} trapped restoreFocus>
<frame Size={UDim2.fromOffset(320, 220)}>{props.children}</frame>
</FocusScope>
);
}

Use asChild when you want the scope root to be your frame instead of an extra wrapper — the child element receives the scope’s ref:

<FocusScope active={props.open} trapped asChild>
<frame Size={UDim2.fromOffset(320, 220)}>{props.children}</frame>
</FocusScope>
PropTypeDescription
activebooleanWhether the scope is participating. Defaults to true. Flipping to false triggers restore.
trappedbooleanKeeps selection inside this scope while it is the topmost trapped scope. Defaults to false.
restoreFocusbooleanRestores selection to the previously selected object when the scope deactivates. Defaults to true.
asChildbooleanUse the single child element as the scope root instead of rendering a wrapper frame.
childrenReact.ReactNodeThe scope contents.

Trapping selection

A trapped scope keeps gamepad and keyboard selection inside it. While a trapped scope is active, the manager refuses to resolve any node outside its root. If the player moves selection out, or external code sets SelectedObject to something outside, selection is pulled back to a focusable node inside the scope.

ModalSurface.tsx
import { FocusScope } from "@lattice-ui/react-focus";
export function ModalSurface(props: { open: boolean; children: React.ReactNode }) {
// While open, selection cannot leave this frame.
return (
<FocusScope active={props.open} trapped>
<frame Size={UDim2.fromOffset(360, 240)}>{props.children}</frame>
</FocusScope>
);
}

Trapping is layer-aware, not a stack of mutually exclusive locks. When several trapped scopes are active, the manager picks the topmost one and traps within it. It ranks first by FocusLayerProvider layer order, then by registration order. Closing the top scope hands the trap to the next one down. This is exactly what lets a Popover open inside a Dialog without either fighting the other.

Restoring selection

When a scope with restoreFocus activates, it captures a restore snapshot: the node (or raw GuiObject) that was selected just before. When that scope deactivates, the manager replays the snapshot, returning selection to whatever opened the surface — typically the trigger.

Restoration degrades gracefully. If the snapshot node is gone — unmounted, now invisible, or disabled — the manager walks up to the parent scope and selects its best fallback. Failing that, it falls back to the topmost remaining trapped scope. It never leaves selection pointing at a dead instance.

You can drive this yourself for surfaces you build outside the primitives:

useRestoreOnClose.ts
import { React } from "@rbxts/react";
import {
captureRestoreSnapshot,
restoreSnapshot,
type FocusRestoreSnapshot,
} from "@lattice-ui/react-focus";
export function useRestoreOnClose(open: boolean) {
const snapshotRef = React.useRef<FocusRestoreSnapshot>();
React.useEffect(() => {
if (open) {
// Remember what was selected before the surface took over.
snapshotRef.current = captureRestoreSnapshot();
return;
}
// On close, hand selection back.
restoreSnapshot(snapshotRef.current);
snapshotRef.current = undefined;
}, [open]);
}

Moving selection by hand

Sometimes you need to put selection somewhere explicitly — the first item of a freshly opened list, a confirm button, a recovered field after validation. The manager exposes imperative helpers:

  • focusGuiObject(guiObject) — select a specific GuiObject (registering it implicitly if needed). Returns the object that ended up selected, or undefined if it was not focusable.
  • focusNode(nodeId) — select a known node by id.
  • getFocusedGuiObject() — read the currently focused object from the model.
  • clearFocus() — clear selection.
autoFocusFirst.ts
import { focusGuiObject } from "@lattice-ui/react-focus";
export function autoFocusFirst(firstItem: GuiObject | undefined) {
// Defer a frame so the instance is parented and visible before selecting.
task.defer(() => {
focusGuiObject(firstItem);
});
}

Ordered selection

Grids, menus and radio groups need directional movement: “next item”, “previous item”. It has to skip disabled and hidden entries and wrap predictably. The ordered-selection helpers operate on a list of entries you maintain (each with an id, an order, and a ref) and resolve which one to move to.

useArrowNavigation.ts
import {
getCurrentOrderedSelectionEntry,
getRelativeOrderedSelectionEntry,
focusOrderedSelectionEntry,
type OrderedSelectionEntry,
type OrderedSelectionDirection,
} from "@lattice-ui/react-focus";
export function moveSelection(
entries: Array<OrderedSelectionEntry>,
direction: OrderedSelectionDirection,
) {
const current = getCurrentOrderedSelectionEntry(entries);
const next = getRelativeOrderedSelectionEntry(entries, current?.id, direction);
focusOrderedSelectionEntry(next);
}

Key behaviors to rely on:

  • Entries are sorted by their order field, not array position, so registration order does not have to match visual order.
  • getRelativeOrderedSelectionEntry filters out unavailable entries (not present, disabled, hidden, or not Selectable) before stepping, and clamps at the ends — it does not wrap. Add your own wrap if you want it.
  • With no current selection, a +1 direction lands on the first available entry and -1 on the last.

How primitives wire all of this

When you use Dialog or Menu, the wiring above is already done. Dialog.Content renders a FocusScope with trapFocus and restoreFocus. Dialog.Trigger registers a focus node, so it is the natural restore target. You only reach for @lattice-ui/react-focus directly when building a selectable surface the primitives do not cover.

Reading what a primitive already does
// Equivalent to what Dialog.Content sets up for you:
<FocusScope active={open} trapped restoreFocus>
{/* trigger captured the restore snapshot when this activated */}
{children}
</FocusScope>