@lattice-ui/react-focusStable directiondepends on runtimeFocus 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;<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.
| 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;};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.
| 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, 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;focusNodemoves focus to a registered node by id.focusGuiObjectmoves focus to aGuiObject, registering an implicit node for it if one is not already tracked. Handy for focusing arbitrary instances that never calleduseFocusNode.clearFocusdrops focus entirely, then re-runs trap enforcement (a trapped scope may immediately reclaim selection).canFocusNodereports whether a node could currently receive focus, without moving it.getFocusedNode/getFocusedGuiObjectread the currently focused node record or itsGuiObject.captureRestoreSnapshotrecords the current selection as a restore target.restoreSnapshotreturns 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;const next = getRelativeOrderedSelectionEntry(entries, currentId, 1);focusOrderedSelectionEntry(next);getOrderedSelectionEntriesreturns the entries sorted by theirorderfield.isOrderedSelectionEntryAvailablereports whether an entry is present, visible, not disabled, andSelectable.findOrderedSelectionEntryreturns the first available entry matching a predicate.getCurrentOrderedSelectionEntryreturns the available entry whose ref matches the currently focusedGuiObject.getFirstOrderedSelectionEntryreturns the first available entry in order.getRelativeOrderedSelectionEntrystepsdirection,1forward or-1back, fromcurrentIdacross only the available entries, clamping at the ends. With no current entry it returns the first going forward, or the last going back.focusOrderedSelectionEntrymoves focus to an entry’sGuiObjectthrough the focus manager.
Roblox tracks selection through a single GuiService.SelectedObject. Focus layers a multi-scope model over it and writes back the one node that should hold selection. Gamepad input can change SelectedObject outside React, so an active FocusScope retains the external bridge and those changes are reconciled rather than fought.
Only GuiObjects with Selectable = true that are effectively visible can be mirrored to GuiService.SelectedObject. A node tracked with syncToRoblox: false will not drive gamepad selection until it is selectable and visible.