Focus boundary
A focus boundary is a virtual focus system that runs in TypeScript. A single SceneGraph node holds real focus (focusable=true), and all focus routing and key handling within the subtree happens in TypeScript with SolidJS signals, without sending key or focus events to BrightScript and back.
To use a focus boundary, you wrap your components in a FocusBoundary component and call the useFocusable hook in each component that can receive focus.
Focus changes are signal updates, and boundaries nest. See Core design for more information.
Understanding the limitations of native SceneGraph focus
The native SceneGraph focus model works through commands that you call from BrightScript. It has the following limitations:
- Moving focus requires
node.setFocus(true)through field observations. - Key events live in
onKeyEvent()BrightScript functions that are distributed across components. - Cross-component focus navigation requires round trips between threads (render, program, and back to render).
- SceneGraph does not have a natural way to express complex navigation patterns in TypeScript.
Core design
FocusBoundary renders a focusable SceneGraph node that captures all SceneGraph key events. Children register themselves via the useFocusable hook and receive virtual focus — a signal-based system where focus changes are instant reactive updates, not SceneGraph mutations.
Key benefits:
- Focus changes are signal updates — zero latency
- Components use
isFocused()as a reactive accessor to drive visual state - All key routing is centralized per boundary
- Complex navigation (linear, grid, explicit graphs) is just TypeScript logic
- Boundaries nest, with parent delegating to the active child boundary
FocusBoundary component
FocusBoundary component<FocusBoundary
keyEvent={inputs.keyEvent} // Key events from BrightScript (root boundary only)
setHandledKeys={setHandledKeys} // Writer for the handledKeys SG field (root only)
focusActive={inputs.focusActive} // SG focus state signal (root only)
initialFocus={menuRef} // Ref to focus on mount
navigation="vertical" // "vertical" | "horizontal" | "grid"
rememberLastFocus={true} // Restore last focused child on re-entry
onUnhandledKey={{ back: onPress(() => goBack()) }}
>
<MenuItem focusId={menuRef} label="Home" />
<MenuItem label="Search" />
</FocusBoundary>Props:
| Prop | Type | Description |
|---|---|---|
initialFocus | object | Ref that receives focus on mount |
navigation | "vertical" | "horizontal" | "grid" | Directional navigation strategy |
navigationMap | Map<object, Partial<Record<Direction, object>>> | Explicit neighbor mapping (overrides linear nav) |
keyEvent | RemoteKeyEvent | Key events from BrightScript (root boundary only) |
setHandledKeys | (keys: Record<string, boolean>) => void | Writes handled keys to SG field (root only) |
handleAllKeys | boolean | Claim all keys unconditionally |
focusActive | boolean | Reactive SG focus state (root only) |
focusId | object | Identity for registering nested boundaries in a parent |
rememberLastFocus | boolean | Restore last focused child on re-entry |
onUnhandledKey | Record<string, KeyPressHandler> | Fallback handlers after child declines and nav fails. An entry returning bubble declines the key, keeping it bubbling |
RootFocusBoundary component
RootFocusBoundary componentRootFocusBoundary is the preferred, explicit entry point for the boundary that talks to a FocusBoundaryBase anchor node. It owns every anchor-node-wired concern and additionally absorbs focus-path tracking (previously a separate FocusPathProvider). Nested subtrees still use FocusBoundary with focusId.
<RootFocusBoundary
keyEvent={inputs.keyEvent} // Key events from BrightScript
focusActive={inputs.focusActive} // SG focus state signal
setHandledKeys={outputs.handledKeys} // Writer for the handledKeys SG field
setFocusPath={outputs.focusPath} // Optional — enables focus-path tracking (renders a wrapper <group>)
initialFocus={menuRef}
navigation="vertical"
onUnhandledKey={{ back: onPress(() => goBack()) }}
>
<MenuItem focusId={menuRef} label="Home" />
<MenuItem label="Search" />
</RootFocusBoundary>Root-only props: keyEvent, focusActive, setHandledKeys, handleAllKeys, setFocusPath. Shared with FocusBoundary: initialFocus, navigation, navigationMap, onUnhandledKey.
An app may have more than one RootFocusBoundary — one per anchor node that extends FocusBoundaryBase (for example, a scene plus a separately-rendered overlay). Each observes its own keyEvent, and only the one in the SceneGraph focus chain is active at a time via focusActive.
FocusBoundarystill auto-detects the root role when givenkeyEvent(or rendered without a parent boundary), butRootFocusBoundarymakes the intent explicit and is the recommended form for new code.
useFocusable hook
useFocusable hookComponents participate in a boundary by calling useFocusable:
const MenuItem = (props) => {
const { ref, isFocused, hasFootprint, focus } = useFocusable({
onKey: {
OK: onPress(() => props.onSelect?.()),
// Equivalent to the line above: OK: (press) => { if (press) props.onSelect?.(); }
pause: (press) => { if (press) onDown(); else onUp(); }, // keep raw form when you need both edges
play: { when: () => !props.disabled, handle: (press) => { /* ... */ } }
},
onFocusGained: () => { /* optional */ },
onFocusLost: () => { /* optional */ },
canReceiveFocus: () => !props.disabled
});
return (
<rectangle ref={ref} color={isFocused() ? "0xFF0000FF" : "0x808080FF"}>
<label text={props.label} />
</rectangle>
);
};Options:
| Option | Type | Description |
|---|---|---|
onKey | Record<string, KeyHandlerEntry> | Declarative key handler map |
onFocusGained | () => void | Called when virtual focus is gained |
onFocusLost | () => void | Called when virtual focus is lost |
canReceiveFocus | () => boolean | Reactive gate — skip this item during navigation when false |
focusId | object | Optional pre-created stable identity. Omit it unless the id is referenced elsewhere — see When do you need a focusId? |
Returns:
| Value | Type | Description |
|---|---|---|
ref | (el: unknown) => void | Attach to JSX root element |
focusId | object | Stable identity for this focusable |
isFocused | () => boolean | Reactive — true when this item has virtual focus |
hasFootprint | () => boolean | True when item is the remembered focus target but boundary is inactive |
hasReturnFootprint | () => boolean | True when focus lands on this item on its way back; follows the focus chain |
focus | () => void | Moves virtual focus to this item from your code |
hasFootprint
hasFootprintWhen a boundary loses SG focus (for example, user navigates to a sibling boundary), the currently focused ref is remembered. hasFootprint() returns true for that ref — you can use it to render a dimmed focus indicator showing where focus will return.
<rectangle color={isFocused() ? "0x0055FFFF" : hasFootprint() ? "0x003388FF" : "0x333333FF"} />hasFootprint() looks at its own boundary only. Every inactive boundary marks its own last focused child, including nested regions focus will not pass through on the way back, and regions with rememberLastFocus={false} that reopen at initialFocus.
hasReturnFootprint
hasReturnFootprinthasReturnFootprint() marks only items focus will land on. It is true when the item's boundary is inactive, every boundary between it and the nearest active ancestor is on the return path, and the item is its boundary's return target: the last focused child with rememberLastFocus, otherwise initialFocus. A root boundary toggled through focusActive keeps its focused child.
That is one item per dormant sibling region under the nearest active ancestor: with three side-by-side regions and focus in the first, the other two each mark the item focus reaches when the user moves into them. The tree shows exactly one footprint only while the root itself is inactive.
useFocusActions().hasReturnFootprint is the same check for the boundary itself — use it to dim or frame a whole region focus will return to.
<rectangle color={isFocused() ? "0x0055FFFF" : hasReturnFootprint() ? "0x003388FF" : "0x333333FF"} />When do you need a focusId?
focusId?Guidance for humans and AI agents: You can create a stable id (
const xRef: object = {}
passed touseFocusable({ focusId })/ a component'sfocusId) only when something else references
that same identity. You must not hand a unique id to every focusable "just in case" — it is
noise that misleads readers into thinking each id is load-bearing.
useFocusable (and FocusBoundary, via focusId) auto-creates its own identity when no ref is passed. Navigation between siblings works off registration order, not ref identity, so most leaves need no ref at all.
A ref is needed only when its identity is consumed by one of:
initialFocus={ref}on aFocusBoundary/RootFocusBoundary- an entry in a
navigationMap(both keys and neighbor values) - an imperative
useFocusActions().setFocus(focusId)call, or afocusedId() === focusIdcomparison - a
FlexGridfocusId(presence of the id switches the grid into top-level boundary mode)
If none of the above reference it, drop the ref and let the hook create one.
// ❌ Don't: a unique ref per item when only the first is referenced by initialFocus.
const item0Ref = {}, item1Ref = {}, item2Ref = {};
<FocusBoundary initialFocus={item0Ref} navigation="vertical">
<MenuItem focusId={item0Ref} label="Home" />
<MenuItem focusId={item1Ref} label="Search" /> {/* item1Ref/item2Ref are never referenced */}
<MenuItem focusId={item2Ref} label="Settings" />
</FocusBoundary>
// ✅ Do: keep only the ref that initialFocus needs; the rest auto-create.
const item0Ref = {};
<FocusBoundary initialFocus={item0Ref} navigation="vertical">
<MenuItem focusId={item0Ref} label="Home" />
<MenuItem label="Search" />
<MenuItem label="Settings" />
</FocusBoundary>For data-driven lists where initialFocus targets a dynamic element (for example, initialFocus={tabRefs[activeTab()]}), a one-ref-per-item array is legitimate — the array is what makes the dynamic target addressable.
Imperative focus
Apps can move virtual focus from code with useFocusActions(), as in the following example:
const SidebarButton = (props) => {
const { setFocus } = useFocusActions();
return (
<button
onPress={() => setFocus(props.targetFocusId)}
/>
);
};The setFocus function moves focus to any focus identity registered in the nearest FocusBoundary: the same object that you passed as focusId, which the examples in this document name …Ref. You can use it when app logic needs to jump focus across siblings or into a nested boundary without waiting for directional navigation.
You must pass the identity, not the
reffromuseFocusable(). The hook returns both, as separate fields:refis a node-ref callback andfocusIdis the identity. Both are objects; therefore,setFocus(ref)typechecks, andsetFocusdoes not check whether the object it receives was ever registered. As a result, focus leaves the current item and lands on nothing, everyisFocused()readsfalse, and key dispatch does not find a handler.
For custom dialogs and overlays that use this pattern (enter with
setFocus, dismiss withback, restore withrememberLastFocus), theModalBoundaryhigher-order component anduseModalhook encapsulate the whole flow.
If the currently focused item or nested FocusBoundary is removed, focus falls back to the previous registered neighbor, or the next one if the item does not have a previous neighbor. If nothing remains, the boundary clears its focused ref.
Key handling
This section describes how a focus boundary declares, dispatches, and releases key handlers.
Declarative key map
You declare keys as a Record<string, KeyHandlerEntry> on useFocusable. Each entry is either a plain handler or a conditional handler:
// Plain handler: always active. The handler receives `press` (true on key-down, false on key-up).
onKey: { OK: (press) => { if (press) doSomething(); } }
// Conditional handler: a reactive gate controls availability
onKey: { OK: { when: () => !props.disabled, handle: (press) => { /* ... */ } } }A plain handler fires on both key-down and key-up; therefore, a handler that needs only one edge wraps its logic in an if (press) guard. The onPress and onRelease helpers remove that guard. They wrap a callback that takes no arguments so that it fires only on the matching edge, as in the following example:
import { onPress, onRelease } from "@roku-sdk/navigation";
onKey: {
OK: onPress(() => doSomething()), // fires on key-down only
play: onRelease(() => stop()) // fires on key-up only
}They also work anywhere that accepts a KeyPressHandler, such as onUnhandledKey:
onUnhandledKey: { back: onPress(() => goBack()) }You can use the raw (press) => { ... } form when a single key needs to handle both edges, and the conditional { when, handle } form when a key should be claimed only while a reactive gate is open.
The boundary derives which keys are handled from the onKey map (it re-evaluates when gates whenever the signals they read change). The boundary does not have a separate handledKeys declaration. The onKey map is the single source of truth; therefore, the declaration and the handling cannot drift apart.
Key dispatch chain
When a key arrives, the boundary processes it in the following order:
- Focused child's
onKey: If the key matches an entry (and thewhengate passes), the handler fires. The boundary consumes the event unless the handler declines it. - Directional navigation: If the key is a direction (up, down, left, or right), the boundary attempts to move focus with the navigation strategy. If focus moves, the boundary consumes the event. This step runs on key-down only.
onUnhandledKey: If the boundary has a fallback handler for this key, the handler fires. The boundary consumes the event unless the handler declines it.- Bubble: The boundary reports the key as not consumed. If this is a nested boundary, the parent boundary repeats the chain.
The boundary, not individual components, owns directional keys. The boundary declares them as handled only when a valid navigation target exists for the current focus position.
Key releases
A release runs the same chain, with step 2 skipped. A key-up never moves focus, and running the search on both edges would step twice per keypress. A release that nothing consumes returns false and bubbles, as an unconsumed press does.
Because the chain starts from current focus, the boundary delivers a release wherever focus is when the key comes up. Focus moves freely while a key is held; therefore, a press and its release can land on different handlers:
- A hold may not get its key-up. If you hold
rightuntil an inner grid runs out of columns and something else takes over, or SceneGraph focus is lost during the hold, the handler that started a timer on key-down never receives the release. Anything that holds state across a keypress should record that it saw the press, and should clear that state on focus loss as well as on key-up. - A closed
whengate suppresses the release. The boundary treats a{ when, handle }entry whose gate closed since the press as unhandled on the key-up too; therefore, the release passes the entry by and keeps bubbling.
When a handler needs its key-up rather than merely tolerating its absence, you should set captureKeyPress on the boundary that owns it. See Guaranteeing a key-up with captureKeyPress for more information.
"Bubbles" here means to the parent boundary. A root boundary does not have a parent, and the false it returns does not travel any further. FocusBoundaryBase hands the key to TypeScript by writing a field and returns before TypeScript has decided anything; therefore, it reports every key it forwards as handled. If a sibling SceneGraph component with its own onKeyEvent needs those keys, you should keep them out of handledKeys rather than rely on the boundary to hand them back.
You should guard on press for anything that should happen once per keypress; the onPress helper does that for you.
Declining a key you claimed
Every key handler (a leaf's onKey entry and a boundary's onUnhandledKey entry alike) is a KeyPressHandler, as in the following type definitions:
import { bubble } from "@roku-sdk/navigation";
type KeyResult = void | {} | null | undefined | typeof bubble;
type KeyPressHandler = (press: boolean) => KeyResult;A handler consumes its key unless it returns bubble. Any other value consumes the key (nothing, true, false, or whatever a SolidJS setter returns); as a result, back: onPress(() => setOpen(false)) closes the panel and stops there. That is correct for a fallback like "back leaves this section" and incorrect for a handler that only sometimes acts; therefore, you must return bubble when the handler did nothing, as in the following example:
<FocusBoundary
onUnhandledKey={{
down: onPress(() => grid.tryMoveDown() || bubble),
back: onPress(() => goBack())
}}
>The tryMoveDown function returns false at the last row, and || bubble turns that into bubble. You must use || bubble only on a function that returns a real boolean because it declines on any falsy value. For a callback that you own (for example, an onBack prop), you can type it to return KeyResult and return bubble from it instead; therefore, the key handler can pass the result straight through. Returning bubble declines the key, and the boundary carries on and ultimately bubbles the key to its parent, which gives the region around this one a chance to move. The back key always stops here.
The onPress and onRelease helpers pass their handler's result through on the edge they fire on, and consume the other edge. As a result, a press-only handler never lets its key-up pass the boundary that stopped the press.
A bubbled key continues through the rest of the chain in order: directional navigation (key-down only), then the boundary's own onUnhandledKey, then the parent boundary. You can use both the when gate and bubble together rather than choosing between them. The when gate says "I never own this key in this state"; therefore, BrightScript does not claim the key at all. Returning bubble says "I own this key but did nothing with this press."
Migrating from 2.15: In 2.15.0 an exact
falsedeclined the key. It now consumes the key, as it did in 2.14. Search for handlers that returnfalseon purpose and change them to returnbubble.
A nested FlexGrid uses all of this through its declineUnhandledNavigation config to hand its edges to the region around it.
Guaranteeing a key-up with captureKeyPress
captureKeyPressDeclining is how a handler that owns one edge stays out of the way on the other. It is not enough when the handler that needs the key-up is above the handler that receives the key-up first, because the outer handler cannot make the inner one decline.
The FlexGrid smooth-scroll case has this shape. The grid consumes a movement press from onUnhandledKey and starts a hold timer. The glide then carries the grid's virtual focus onto a cell that registered its own boundary, and the key-up goes to that cell's zones before the grid. A zone that owns the key on both edges consumes it there; as a result, the grid never learns that the hold ended, and the glide runs to the end of the data.
The captureKeyPress option binds the two edges together, as in the following example:
<FocusBoundary focusId={gridRef} captureKeyPress onUnhandledKey={movementKeys}>When a handler that this boundary owns consumes a press (an onUnhandledKey entry, or the onKey entry of whichever child was focused at the time), the boundary delivers the matching release straight to that handler ahead of the dispatch chain, and keeps the key in handledKeys until the release arrives. The second half makes it a guarantee rather than a preference: the boundary otherwise derives handledKeys from current focus, and BrightScript filters out the release of a key that leaves the set mid-hold before TypeScript runs.
The following table describes how captureKeyPress behaves:
| Aspect | Behavior |
|---|---|
| Delivery | Exactly once, to the handler that consumed the press, skipping the descent |
| Declining | Returning bubble still declines; the release then falls through to the ordinary chain. |
when gates | A gate that closed since the press no longer withholds the release because routing is by handler identity. |
| Owner goes away | The capture is dropped undelivered if its boundary unmounts, loses focus, or its focused leaf unregisters. |
| Synthesis | The boundary does not create a release for a key that never came up. |
| Key-up that never arrives | Bounded by the focus-loss drop, not by a timer. Losing focus ends a hold; therefore, it ends the capture and unpins the key. Otherwise, a key-up lost to device sleep or a stolen SceneGraph focus would leave the key claimed for the life of the boundary. |
| Which keys | Any key a handler on this boundary consumed, directions and selection keys alike |
| Forwarded keys | A release pushed in with useFocusActions().dispatchKey takes the ordinary chain. Capture routing happens at the root; therefore, a nested boundary never answers true on behalf of an ancestor's handler. The real key-up is still capture-routed when it arrives. |
| Cost when off | One integer comparison per release |
You can turn on captureKeyPress for a boundary whose handlers span both key edges. You can leave it off otherwise because a handler that acts on a single edge wants the current-focus routing. The FlexGrid component exposes it as its own captureKeyPress config flag.
Navigation strategies
This section describes the navigation strategies that you can set on a boundary with the navigation prop or the navigationMap prop.
Linear ("vertical" / "horizontal")
"vertical" / "horizontal")Registration order determines navigation order. Up and down (vertical) or left and right (horizontal) move through the list with a delta of ±1, skipping items where canReceiveFocus returns false.
Grid ("grid")
"grid")All four directional keys navigate through focusables in registration order with a delta of ±1. You can use this strategy for simple linear layouts that need navigation in all directions.
Explicit graph (navigationMap)
navigationMap)For irregular layouts, you can pass a Map<ref, { up?, down?, left?, right? }> that maps each ref to its neighbors:
const playRef = {}, pauseRef = {}, stopRef = {};
<FocusBoundary
initialFocus={playRef}
navigationMap={new Map([
[playRef, { right: stopRef, down: pauseRef }],
[pauseRef, { up: playRef, right: stopRef }],
[stopRef, { left: playRef, up: pauseRef }],
])}
>
<PlayButton focusId={playRef} />
<PauseButton focusId={pauseRef} />
<StopButton focusId={stopRef} />
</FocusBoundary>Nesting
Boundaries nest for complex screens (for example, a sidebar and a content area). A nested boundary registers itself as a single focusable in its parent with focusId, as in the following example:
<RootFocusBoundary keyEvent={inputs.keyEvent} initialFocus={sidebarRef} navigation="horizontal">
<FocusBoundary focusId={sidebarRef} navigation="vertical" rememberLastFocus>
{/* Sidebar items */}
</FocusBoundary>
<FocusBoundary focusId={contentRef} navigation="vertical">
{/* Content items */}
</FocusBoundary>
</RootFocusBoundary>Nesting works as follows:
- The outer (root) boundary holds actual SceneGraph focus.
- Each nested boundary registers with the parent as a focusable and provides a
dispatchKeyfunction. - When the parent's focused child is a nested boundary, the parent delegates key events to that boundary's dispatch.
- If the nested boundary cannot handle the key, it returns
falseand the parent continues its own dispatch chain. - All of this happens in TypeScript without any additional BrightScript involvement.
Pooled and virtualized containers
A focusable registers with the boundary it was rendered under, once, when it mounts. That is a problem for a container that recycles one rendered subtree across several boundaries, such as a cell pool shared by two grids: when a cell moves to the other grid, its focusables would stay registered with the first grid's boundary.
@roku-sdk/navigation/focus-relay solves this with a focus relay, a stable focus parent that forwards registrations, focus queries and keys to whichever boundary it is currently bound to. Most apps never need it. FlexGrid already uses it for shared pools, so you only need it if you are writing your own pooled container.
import { createFocusRelay, FocusRelayProvider, useFocusRelayTarget } from "@roku-sdk/navigation/focus-relay";
// Under the boundary that first displays the recycled subtree:
const target = useFocusRelayTarget();
const relay = target ? createFocusRelay(target) : null;
if (relay) onCleanup(() => relay.unbind());
// Render the subtree under the relay instead of directly under the boundary:
<FocusRelayProvider relay={relay}>{cell}</FocusRelayProvider>;
// Under each other boundary that can display the subtree, capture its target during setup...
const otherTarget = useFocusRelayTarget();
// ...and bind the relay to it when that boundary takes the subtree over:
if (otherTarget) relay.bind(otherTarget);Every boundary a relay is bound to must sit under the same RootFocusBoundary. When a target boundary unmounts it drops the relay itself, but the owner of the recycled subtree must still call unbind() when the subtree is disposed.
BrightScript bridge
Only the root boundary communicates with BrightScript. The bridge uses the FocusBoundaryBaseShim XML component on existing firmware. TypeScript descriptors keep extends: "FocusBoundaryBase"; registration chooses the native FocusBoundaryBase when hasFeature("focusBoundaryBase") is true, otherwise the shim. XML extends and BrightScript CreateObject calls are not remapped: use FocusBoundaryBaseShim for those on existing firmware. The bridge exposes the following fields:
| Field | Direction | Description |
|---|---|---|
handledKeys | TypeScript to BrightScript | Record<string, boolean>: which keys to claim in onKeyEvent |
keyEvent | BrightScript to TypeScript | { key, press }: written when a claimed key is pressed |
focusActive | BrightScript to TypeScript | boolean: whether this node is in the SceneGraph focus chain |
focusPath | TypeScript to BrightScript | string: focus path for automation and debugging |
The BrightScript onKeyEvent function checks the pre-written handledKeys field synchronously and returns immediately, without a TypeScript round trip:
function onKeyEvent(key as String, press as Boolean) as Boolean
handledKeys = m.top.handledKeys
if handledKeys <> invalid and handledKeys.count() > 0 then
if handledKeys[key] <> true then return false
end if
m.top.keyEvent = { key: key, press: press }
return true
end functionThe handledKeys field updates whenever virtual focus changes or a focused component's key capabilities change (for example, a button becomes disabled).
The filter works the same for key-down and key-up, and the boundary derives it from current focus. This matters for held keys: if a key drops out of handledKeys mid-hold (for example, you hold down into the last row, where directional navigation stops declaring it), the filter removes the key-up here and it never reaches TypeScript. You must not rely on a key-up alone to tear down state a key-down armed, unless the boundary that owns the handler sets captureKeyPress, which pins a captured key into the set until its release arrives.
focusPath
focusPathYou can pass setFocusPath to RootFocusBoundary to publish the currently focused virtual leaf as a real SceneGraph index path. When you pass it, the root boundary renders a wrapper <group>, listens for useFocusable ref updates, computes the path from the anchor node's SceneGraph root to the focused DOM node, and writes that path through the setter. If you omit setFocusPath, the root boundary does not create a node (without a wrapper <group> and without any tracking cost).
The following example passes setFocusPath to a RootFocusBoundary:
import { type RemoteKeyEvent } from "@roku-sdk/interfaces";
const MySceneDescription = {
componentName: "MyScene",
extends: "FocusBoundaryBase" as const,
inputFields: {
keyEvent: FieldType.Object(),
focusActive: FieldType.Boolean(true),
},
outputFields: {
handledKeys: FieldType.Object(),
focusPath: FieldType.String(),
},
};
const MyScene: AnchorNodeImplementation<typeof MySceneDescription> = (inputs, outputs) => (
<RootFocusBoundary
keyEvent={inputs.keyEvent as RemoteKeyEvent | null}
setHandledKeys={outputs.handledKeys}
setFocusPath={outputs.focusPath}
focusActive={inputs.focusActive as boolean}
initialFocus={itemRef}
>
<MenuItem focusId={itemRef} />
</RootFocusBoundary>
);You must attach the ref returned by useFocusable() to the JSX root that should represent the focused leaf. The RootFocusBoundary component uses that DOM node to compute a /-separated SceneGraph child-index path such as "0/1/0/3/0". Automation can read the anchor node's focusPath field with query/app-ui or SceneGraph node queries and walk the SceneGraph tree with those indices.
For path context, see focus-path-provider.tsx. Tracking lives in RootFocusBoundary in root-focus-boundary.tsx. For example usage, see focus-boundary-sg-tree.
focusActive
focusActiveThe focusActive prop tells the root FocusBoundary whether the anchor node is currently in the SceneGraph focus chain. This matters when SceneGraph focus temporarily moves to something outside the virtual tree (for example, a dialog or a native component handoff).
When focusActive becomes false:
- The
isFocused()accessors in the virtual tree returnfalse. - The boundary keeps the focused ref instead of clearing it.
- The focus-gained and focus-lost callbacks fire consistently with the active state.
When focusActive becomes true again, the preserved focus path becomes active again. If the currently focused child is removed while focused, the boundary falls back to the previous registered neighbor, or the next one if the child does not have a previous neighbor. This covers common dialog and temporary child-removal cases: opening a dialog can suspend the virtual focus visuals, and closing or unmounting the dialog lets focus return to the surviving registered item.
Root FocusBoundary guidance
You should use one root FocusBoundary for each anchor node's key events. Nested FocusBoundary components should omit keyEvent and register with their parent with focusId.
Multiple root FocusBoundary components in the same scene each have their own SceneGraph bridge and handledKeys state. You should use multiple roots only when you intentionally want separate SceneGraph focus islands and are prepared to move SceneGraph focus between them.
Comparison to native SceneGraph focus
The following table compares native SceneGraph focus with a focus boundary:
| Aspect | SceneGraph focus | Focus boundary |
|---|---|---|
| Focus movement | node.setFocus(true) | Signal update |
| Key event latency | Field observation round trip | Synchronous dispatch |
| Navigation logic | Distributed across nodes | Centralized in the boundary |
| Custom navigation | Difficult | Pure TypeScript |
| Visual focus state | observeField("focusedChild") | Reactive isFocused() |
| BrightScript surface area | Large | Minimal (one bridge point) |
Understanding unsupported cases
The focus boundary system does not support the following cases:
- Dialog focus handling: When a dialog opens, SceneGraph moves focus to the dialog node. The boundary does not have a mechanism to detect this, suspend its virtual focus state, and restore it when the dialog closes.
- Child SceneGraph components that call
setFocus: If a SceneGraph component inside a boundary (for example, aRowListor a custom BrightScript component) callssetFocus(true)internally, SceneGraph focus moves away from the boundary root. The boundary does not detect this, and its virtual focus state becomes stale. - Interleaving of root
FocusBoundarycomponents: When multiple root-levelFocusBoundarycomponents exist in the same scene (for example, a header boundary and a content boundary, each with its ownkeyEventandsetHandledKeysbridge), the boundaries do not coordinate with each other. Moving SceneGraph focus from one root boundary to another requires manual wiring, andhandledKeysfrom the previously active boundary may remain stale.
Examples
The following example apps demonstrate focus boundaries:
- The Focus Boundary example apps include basic usage, nesting, navigation strategies, conditional focus, and declining keys.
- The Focus Boundary SG tree example mixes
FocusBoundarywith SceneGraph components. - The
cowboy-tvMiniGrid file demonstrates a nestedFlexGridwith a three-zone cell boundary. See Nested grids for more information.
Related documentation
- Modal boundary: Describes
ModalBoundaryand theuseModalhook, which encapsulate the custom-dialog focus pattern that is built onFocusBoundary. - Focus Boundary example apps: Includes example apps that demonstrate the
FocusBoundaryanduseFocusableAPIs. - KeyHandler: Describes
KeyHandler, a focusable SceneGraph component that captures key events and forwards them to TypeScript.
Updated about 5 hours ago