Latticereference

Focus

Focus scopes, nodes, trapping and restoration, plus ordered selection movement.

@lattice-ui/react-focusStable directiondepends on runtime

Focus is the selection engine behind every layered Lattice UI primitive. Roblox exposes a single GuiService.SelectedObject, with no scopes, traps or restore targets. Focus tracks a registry of focusable nodes and scopes, decides which node should hold selection, and bridges that decision to and from GuiService.SelectedObject.

Dialog, Popover, Menu, Select and the other overlay primitives rely on it to trap gamepad and keyboard selection inside the active surface and restore it on close. Use the same primitives directly for a custom surface.

Import

import {
FocusScope,
useFocusNode,
FocusScopeProvider,
FocusLayerProvider,
useFocusScopeId,
useFocusLayerOrder,
focusNode,
focusGuiObject,
clearFocus,
getFocusedNode,
getFocusedGuiObject,
} from "@lattice-ui/react-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 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 layer order so nested scopes resolve in the right order.

With trapped set on an active scope, selection that escapes is pulled back to the best fallback node inside it. With restoreFocus, the scope captures whatever was selected before it activated and returns selection there on deactivation.

It renders a transparent full-size frame as its root, or with asChild merges the scope root onto your single child element.

PropTypeDescription
activebooleanWhether the scope participates in focus management. Defaults to true.
trappedbooleanKeeps selection inside the scope root while active. Defaults to false.
restoreFocusbooleanCaptures selection on activation and restores it on deactivation. Defaults to true.
asChildbooleanMerges the scope root onto the single child element instead of rendering a frame.
childrenReact.ReactNodeThe 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 component’s lifetime, and returns a ref holding the node id. It inherits the nearest scope id unless you pass scopeId, and disabled or invisible nodes are skipped by resolution and trapping. With syncToRoblox enabled, the node mirrors to GuiService.SelectedObject while focused. Set it false for a logically-focused node that should not drive native selection.

PropTypeDescription
ref *React.MutableRefObject<GuiObject | undefined>Ref to the GuiObject this node represents.
scopeIdnumberScope to register under. Defaults to the inherited scope from context.
disabledbooleanStatically marks the node as not focusable.
getDisabled() => booleanDynamic disabled check, ORed with the static disabled flag.
getVisible() => boolean | undefinedDynamic visibility override. Returning false removes the node from resolution.
syncToRobloxbooleanMirror 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, so 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 orders win when resolving which scope owns a node, or which trapped scope is on top. The Layer package renders it around each dismissable layer.

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 registry. Functions that move focus return the resolved GuiObject, 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 back FocusScope and useFocusNode. Reach for them only 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 and unregisterFocusNode add and remove nodes. createFocusScopeId allocates a scope id. registerFocusScope records a scope, syncFocusScope re-evaluates it after its inputs change, and unregisterFocusScope tears it down with restore handling. retainExternalFocusBridge and releaseExternalFocusBridge are reference-counted: while one consumer is retained, the manager listens to external GuiService.SelectedObject changes and reconciles them back.

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 toolkit for moving selection through an ordered list, 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 or -1 back, from currentId across only the available entries, clamping at the ends. With no current entry it returns the first going forward, or the last going back.
  • focusOrderedSelectionEntry moves focus to an entry’s GuiObject through the focus manager.