Lattice reference

Focus

A deterministic selection manager for Roblox — focus scopes, focus nodes, focus trapping and restoration, and ordered selection movement.

@lattice-ui/focus Stable direction depends on core

Focus is the selection engine that sits behind every layered Lattice UI primitive. Roblox only exposes a single GuiService.SelectedObject, with no concept of focus scopes, traps, or restore targets. Focus builds those concepts on top: it tracks a registry of focusable nodes and scopes, decides which node should hold selection at any moment, and bridges that decision to and from GuiService.SelectedObject.

Dialog, Popover, Menu, Select, and the other overlay primitives all rely on Focus to trap gamepad/keyboard selection inside the active surface and restore it when the surface closes. You can use the same primitives directly when building a custom surface that needs predictable selection.

Import

import {
FocusScope,
useFocusNode,
FocusScopeProvider,
FocusLayerProvider,
useFocusScopeId,
useFocusLayerOrder,
focusNode,
focusGuiObject,
clearFocus,
getFocusedNode,
getFocusedGuiObject,
} from "@lattice-ui/focus";

API reference

FocusScope

function FocusScope(props: {
active?: boolean;
asChild?: boolean;
trapped?: boolean;
restoreFocus?: boolean;
children?: React.ReactNode;
}): React.Element;
Trapping selection inside a surface
<FocusScope trapped restoreFocus>
<frame Size={UDim2.fromOffset(320, 180)}>
<textbutton Text="Confirm" Selectable />
<textbutton Text="Cancel" Selectable />
</frame>
</FocusScope>

FocusScope declares a region of the UI that participates in focus management. It registers a scope rooted at the rendered GuiObject, provides a scope id to descendants through context, and reads the ambient focus layer order so nested scopes resolve in the right order. When trapped is set and the scope is active, selection is kept inside the scope’s root — if selection escapes, it is pulled back to the best fallback node inside the scope. When restoreFocus is enabled, the scope captures whatever was selected before it activated and returns selection there when it deactivates.

By default FocusScope renders a transparent full-size frame as its root. With asChild, it merges the scope root onto your single child element instead.

Prop Type Description
active boolean Whether the scope participates in focus management. Defaults to true.
trapped boolean Keeps selection inside the scope root while active. Defaults to false.
restoreFocus boolean Captures selection on activation and restores it on deactivation. Defaults to true.
asChild boolean Merges the scope root onto the single child element instead of rendering a frame.
children React.ReactNode The scope contents.

useFocusNode

function useFocusNode(
options: UseFocusNodeOptions,
): React.MutableRefObject<number | undefined>;
type UseFocusNodeOptions = {
ref: React.MutableRefObject<GuiObject | undefined>;
scopeId?: number;
disabled?: boolean;
getDisabled?: () => boolean;
getVisible?: () => boolean | undefined;
syncToRoblox?: boolean;
};
Registering a focusable item
const ref = React.useRef<TextButton>();
const nodeId = useFocusNode({ ref, disabled: props.disabled });
return <textbutton ref={ref} Selectable Text={props.label} />;

Registers a single GuiObject as a focus node for the lifetime of the component and returns a ref holding the assigned node id. The node inherits the nearest scope id from context unless you pass an explicit scopeId. Disabled or invisible nodes are skipped by focus resolution and trapping. When syncToRoblox is left enabled, the node mirrors to GuiService.SelectedObject while it holds focus; set it to false for a logically-focused node that should not drive Roblox’s native selection.

Prop Type Description
ref * React.MutableRefObject<GuiObject | undefined> Ref to the GuiObject this node represents.
scopeId number Scope to register under. Defaults to the inherited scope from context.
disabled boolean Statically marks the node as not focusable.
getDisabled () => boolean Dynamic disabled check, ORed with the static disabled flag.
getVisible () => boolean | undefined Dynamic visibility override. Returning false removes the node from resolution.
syncToRoblox boolean Mirror the node to GuiService.SelectedObject while focused. Defaults to true.

FocusScopeProvider

function FocusScopeProvider(props: {
scopeId?: number;
children?: React.ReactNode;
}): React.Element;

Provides a scope id to descendants. FocusScope renders this for you; use it directly only when wiring scope inheritance manually.

useFocusScopeId

function useFocusScopeId(): number | undefined;

Reads the nearest scope id from context, or undefined outside any scope. useFocusNode uses this to inherit a scope.

FocusLayerProvider

function FocusLayerProvider(props: {
layerOrder?: number;
children?: React.ReactNode;
}): React.Element;

Provides the ambient focus layer order to descendants. Higher layer orders win when resolving which scope owns a node or which trapped scope is on top — the Layer package renders this around each dismissable layer so stacked surfaces trap correctly.

useFocusLayerOrder

function useFocusLayerOrder(): number | undefined;

Reads the ambient layer order from context. FocusScope reads this and reports it to the manager so scope precedence follows layer stacking.

Focus manager functions

The imperative manager API operates on the global focus registry. Functions that move focus return the resolved GuiObject on success, or undefined when the target cannot take focus (missing, disabled, invisible, or outside the active trapped scope).

function focusNode(nodeId: number): GuiObject | undefined;
function focusGuiObject(guiObject: GuiObject | undefined): GuiObject | undefined;
function clearFocus(): void;
function canFocusNode(nodeId: number): boolean;
function getFocusedNode(): FocusNodeRecord | undefined;
function getFocusedGuiObject(): GuiObject | undefined;
function captureRestoreSnapshot(): FocusRestoreSnapshot;
function restoreSnapshot(snapshot: FocusRestoreSnapshot | undefined): GuiObject | undefined;
  • focusNode moves focus to a registered node by id.
  • focusGuiObject moves focus to a GuiObject, registering an implicit node for it if one is not already tracked — handy for focusing arbitrary instances that never called useFocusNode.
  • clearFocus drops focus entirely, then re-runs trap enforcement (a trapped scope may immediately reclaim selection).
  • canFocusNode reports whether a node could currently receive focus, without moving it.
  • getFocusedNode / getFocusedGuiObject read the currently focused node record or its GuiObject.
  • captureRestoreSnapshot records the current selection as a restore target; restoreSnapshot returns focus to a previously captured snapshot (or clears focus when the snapshot is empty).

Focus registration functions

These lower-level functions back FocusScope and useFocusNode. Reach for them only when building focus integration outside React’s lifecycle.

function registerFocusNode(params: RegisterFocusNodeParams): number;
function unregisterFocusNode(nodeId: number): void;
function createFocusScopeId(): number;
function registerFocusScope(scopeId: number, params: RegisterFocusScopeParams): number;
function syncFocusScope(scopeId: number): void;
function unregisterFocusScope(scopeId: number): void;
function retainExternalFocusBridge(): void;
function releaseExternalFocusBridge(): void;

registerFocusNode / unregisterFocusNode add and remove nodes from the registry. createFocusScopeId allocates a unique scope id; registerFocusScope records a scope (its root getter, active/trapped/restore/layer getters), syncFocusScope re-evaluates a scope after its inputs change, and unregisterFocusScope tears it down with restore handling. retainExternalFocusBridge / releaseExternalFocusBridge are reference-counted — while at least one consumer is retained, the manager listens to external GuiService.SelectedObject changes (for example, a gamepad moving selection) and reconciles them back into its model.

Exported manager types

type FocusNodeRecord = {
id: number;
scopeId?: number;
implicit: boolean;
order: number;
getGuiObject: () => GuiObject | undefined;
getDisabled: () => boolean;
getVisible: () => boolean | undefined;
getSyncToRoblox: () => boolean;
};
type FocusRestoreSnapshot = { nodeId?: number };
type RegisterFocusNodeParams = {
scopeId?: number;
getGuiObject: () => GuiObject | undefined;
getDisabled?: () => boolean;
getVisible?: () => boolean | undefined;
getSyncToRoblox?: () => boolean;
};
type RegisterFocusScopeParams = {
parentScopeId?: number;
getRoot: () => GuiObject | undefined;
getActive: () => boolean;
getTrapped: () => boolean;
getRestoreFocus?: () => boolean;
getLayerOrder?: () => number | undefined;
};

Ordered selection helpers

A small toolkit for moving selection through an ordered list of entries — used by roving-selection primitives like Menu, Tabs, and Radio Group. An entry pairs a stable id and sort order with a ref to its GuiObject and optional disabled/visible getters.

type OrderedSelectionDirection = -1 | 1;
type OrderedSelectionEntry = {
id: number;
order: number;
ref: React.MutableRefObject<GuiObject | undefined>;
getDisabled?: () => boolean;
getVisible?: () => boolean;
};
function getOrderedSelectionEntries<T>(entries: Array<T>): Array<T>;
function isOrderedSelectionEntryAvailable(entry: OrderedSelectionEntry): boolean;
function findOrderedSelectionEntry<T>(entries: Array<T>, predicate: (entry: T) => boolean): T | undefined;
function getCurrentOrderedSelectionEntry<T>(entries: Array<T>): T | undefined;
function getFirstOrderedSelectionEntry<T>(entries: Array<T>): T | undefined;
function getRelativeOrderedSelectionEntry<T>(
entries: Array<T>,
currentId: number | undefined,
direction: OrderedSelectionDirection,
): T | undefined;
function focusOrderedSelectionEntry(entry: OrderedSelectionEntry | undefined): void;
Moving to the next selectable entry
const next = getRelativeOrderedSelectionEntry(entries, currentId, 1);
focusOrderedSelectionEntry(next);
  • getOrderedSelectionEntries returns the entries sorted by their order field.
  • isOrderedSelectionEntryAvailable reports whether an entry is present, visible, not disabled, and Selectable.
  • findOrderedSelectionEntry returns the first available entry matching a predicate.
  • getCurrentOrderedSelectionEntry returns the available entry whose ref matches the currently focused GuiObject.
  • getFirstOrderedSelectionEntry returns the first available entry in order.
  • getRelativeOrderedSelectionEntry steps direction (1 forward, -1 back) from currentId across only the available entries, clamping at the ends; with no current entry it returns the first (forward) or last (back).
  • focusOrderedSelectionEntry moves focus to an entry’s GuiObject through the focus manager.