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
GuiObjectthe manager may select, registered withuseFocusNode. 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.
FocusScoperegisters one and shares its id through context, so anyuseFocusNodeinside 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.
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} /> );}The manager never selects a node whose Selectable is false, whose Visible chain is broken, or whose getDisabled() returns true. Keep Selectable and Active in sync with your disabled state: getDisabled mirrors it in the model, Selectable is what Roblox enforces.
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.
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>| Prop | Type | Description |
|---|---|---|
| active | boolean | Whether the scope is participating. Defaults to true. Flipping to false triggers restore. |
| trapped | boolean | Keeps selection inside this scope while it is the topmost trapped scope. Defaults to false. |
| restoreFocus | boolean | Restores selection to the previously selected object when the scope deactivates. Defaults to true. |
| asChild | boolean | Use the single child element as the scope root instead of rendering a wrapper frame. |
| children | React.ReactNode | The 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.
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.
A trapped scope needs something to hold. On activation the manager looks for the scope’s last-focused node, then the first focusable descendant. If the scope has no selectable GuiObject yet, selection clears until one appears. That happens when its content mounts a frame later, for example. Make sure at least one child is Selectable by the time the scope becomes active.
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:
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]);}captureRestoreSnapshot records the focus node id, not the current GuiObject directly. If nothing is focused in the model, it falls back to an implicit node inferred from GuiService.SelectedObject. Capture before you change selection — once your surface has stolen it, the snapshot would point at your own content.
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 specificGuiObject(registering it implicitly if needed). Returns the object that ended up selected, orundefinedif it was not focusable.focusNode(nodeId)— select a known node by id.getFocusedGuiObject()— read the currently focused object from the model.clearFocus()— clear selection.
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); });}A GuiObject is not focusable until it is parented and effectively visible. Right after mount the instance may not satisfy that yet, so focusGuiObject returns undefined. Deferring one frame (or marking the scope active and letting trap enforcement pick it up) is the reliable path.
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.
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
orderfield, not array position, so registration order does not have to match visual order. getRelativeOrderedSelectionEntryfilters out unavailable entries (not present, disabled, hidden, or notSelectable) before stepping, and clamps at the ends — it does not wrap. Add your own wrap if you want it.- With no current selection, a
+1direction lands on the first available entry and-1on the last.
OrderedSelectionDirection is -1 | 1, not an arbitrary number. Map your gamepad or arrow input to one of those two values. For a horizontal group, Left is -1 and Right is 1. For a vertical one, use Up and Down.
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.
// Equivalent to what Dialog.Content sets up for you:<FocusScope active={open} trapped restoreFocus> {/* trigger captured the restore snapshot when this activated */} {children}</FocusScope>