FocusableGroup
The FocusableGroup and Focusable components are SceneGraph-native components that manage cross-component focus navigation.
You wrap each focusable element in a Focusable component, place the Focusable components in a FocusableGroup, and set the nextFocus* props (for example, nextFocusDown) to define where focus moves. Navigation decisions run on the render thread; therefore, focus navigation does not require round trips to the program thread.
For new TypeScript components, you should use
FocusBoundary. It provides the same benefits with a fully TypeScript-side virtual focus system and reactiveisFocused()accessors, and it does not have any SceneGraph focus mutation overhead. If you need BrightScript components to participate directly in focus navigation, you should useFocusableGroup.
Understanding how focus works
Focusing across components involves the following three concerns. The sections after this list describe each concern.
- Initial focus: setting focus after the component tree is fully built
- Moving focus between components: navigating between focusable elements
- Notifications on focus change: reacting to focus changes on the program thread
Setting initial focus
You must set initial focus on every screen with an imperative method or a declarative method. An imperative method sets focus by calling a function; a declarative method sets focus through a prop. The following sections explain why initial focus is required and describe both methods.
Understanding why initial focus is required
In traditional BrightScript, you call setFocus() within component init() methods during node initialization. However, RSG TypeScript works differently. In BrightScript, the component sets focus during component initialization on the render thread. In RSG TypeScript, the render thread applies transactions sequentially to create components.
When RSG TypeScript applies transactions to create the component tree, SceneGraph loses focus if the transactions add children after the initial node creation. This happens for the following reasons:
- The render thread creates the initial node, which can gain focus.
- A subsequent transaction adds children to the node.
- The SceneGraph focus state becomes inconsistent.
- SceneGraph loses focus, and you must re-establish it.
Because the render thread applies transactions one after another, you must set focus from the TypeScript program thread after the complete component tree is established, rather than during individual component initialization.
Low-level API
The low-level API sets focus on a node. The following code shows the FocusApi interface:
export type AnNodeRef = object;
export type TmNodeRef = object;
export interface FocusApi {
setFocus: (anchorNodeRef: AnNodeRef, nodeRef: TmNodeRef, focus: boolean) => void;
}Setting focus with the imperative method
Your app code calls the imperative method on the TypeScript program thread. You can use the method to do the following:
- Set focus on any node in the SceneGraph tree
- Set initial focus on a focusable node in a focusable group
- Set focus on a new focusable node when you add new focusable nodes or delete existing focusable nodes
Calling the focus and blur methods
All primitive components on the program thread contain focus() and blur() methods. To call these methods, you must use a ref (a reference to the component) to access the component. The following example sets and removes focus:
// Set focus on the component
nodeRef.focus();
// Remove focus from the component
nodeRef.blur();Setting focus with the declarative method
Components use the declarative method to set focus on a specific component with the gainFocus prop.
In RSG TypeScript, the declarative prop is more manageable because you can pass the prop through higher-order components (components that wrap other components).
At the end of each transaction, the SDK finds the node to pass to setFocus by traversing from the root of the anchor node and picking the first leaf node whose gainFocus prop is true.
You can also explicitly remove focus by setting gainFocus={false}, which calls the setFocus method with false. The following example sets and removes focus declaratively:
// Set focus declaratively
<button gainFocus={true} text="Focused Button" />
// Remove focus declaratively
<button gainFocus={false} text="Unfocused Button" />Moving focus between components
SceneGraph handles focus within a single component automatically; however, moving focus between components requires explicit coordination.
Moving focus in BrightScript
In traditional BrightScript apps, you handle focus movement in onKeyEvent functions that call setFocus(). The following example moves focus from a RowList to a header navigation when the user presses up on the first item:
function onKeyEvent(key as String, press as Boolean) as Boolean
if press and key = "up"
if m.rowList.inFocus() and m.rowList.itemFocused = 0
m.rowList.setFocus(false)
m.headerNav.setFocus(true)
return true
end if
end if
return false
end functionMoving focus in RSG TypeScript
RSG TypeScript provides multiple methods for cross-component focus movement, with varying performance characteristics.
Moving focus with the round-trip method
You can use the initial focus methods (the gainFocus prop or the focus() method) to move focus between components; however, this method is less efficient because it requires a round trip: program thread → render thread → program thread → render thread.
This approach also requires the TypeScript program thread to do the following:
- Listen for focus change events from SceneGraph components
- Implement decision logic to determine when focus should move to another component
- Handle edge cases, such as focus reaching component boundaries
The following example shows verbose focus management on the program thread:
// Example of verbose focus management on program thread
const [currentFocus, setCurrentFocus] = createSignal("rowList");
const handleRowListFocusChange = (focusData: any) => {
// Listen for focus events
if (focusData.atTopEdge && key === "up") {
// Decision logic on program thread
headerNavRef.focus(); // Another round trip
setCurrentFocus("headerNav");
}
};
// This gets verbose quickly with multiple componentsThis approach is inefficient for the following reasons:
- Threading overhead from round trips
- Verbose focus event handling code on the program thread
- Complex state synchronization between threads
Moving focus with the declarative approach
You should use declarative components that handle focus navigation entirely on the render thread. The following example shows basic cross-component navigation:
<FocusableGroup rememberLastFocus={true}>
<Focusable nextFocusDown="mainContent">
<HeaderNavigation id="headerNav" {...headerProps} />
</Focusable>
<Focusable nextFocusUp="headerNav" nextFocusDown="footerNav">
<rowList id="mainContent" {...props} />
</Focusable>
<Focusable nextFocusUp="mainContent">
<FooterNavigation id="footerNav" {...footerProps} />
</Focusable>
</FocusableGroup>You can move focus across multiple nested FocusableGroup components as long as the target Focusable is within the same parent FocusableGroup. The following example nests two groups inside main-group:
<FocusableGroup id="main-group">
{/* Top section with nested group */}
<Focusable nextFocusDown="button2">
<FocusableGroup id="top-group" rememberLastFocus={true}>
<Focusable nextFocusRight="button1">
<button id="button0" text="Button 0" />
</Focusable>
<Focusable nextFocusLeft="button0">
<button id="button1" text="Button 1" />
</Focusable>
</FocusableGroup>
</Focusable>
{/* Bottom section with another nested group */}
<Focusable nextFocusUp="button0">
<FocusableGroup id="bottom-group" rememberLastFocus={true}>
<Focusable nextFocusRight="button3">
<button id="button2" text="Button 2" />
</Focusable>
<Focusable nextFocusLeft="button2" nextFocusRight="button4">
<button id="button3" text="Button 3" />
</Focusable>
<Focusable nextFocusLeft="button3">
<button id="button4" text="Button 4" />
</Focusable>
</FocusableGroup>
</Focusable>
</FocusableGroup>When you manage each FocusableGroup in its own file or as a shared component, you need to know the specific id of the focusable elements within that shared component to reference them from other components. As a result, each component depends on the internal structure of the other components, which makes the components less reusable.
To avoid this, you can specify the id of the target FocusableGroup instead of the id of individual focusable elements. The system then picks the appropriate focus automatically with the pickFocus functionality. The following example references the group instead of a specific button:
// Shared Navigation Component
interface NavigationBarProps {
id: string;
}
const NavigationBar = ({ id }: NavigationBarProps) => (
<FocusableGroup id={id} rememberLastFocus={true}>
<Focusable nextFocusDown="search" hasPreferredFocus={true}>
<button id="home" text="Home" />
</Focusable>
<Focusable nextFocusUp="home" nextFocusDown="settings">
<button id="search" text="Search" />
</Focusable>
<Focusable nextFocusUp="search">
<button id="settings" text="Settings" />
</Focusable>
</FocusableGroup>
);
// Main Layout - No need to know specific button IDs
<FocusableGroup id="main-layout">
<Focusable nextFocusDown="nav-bar"> {/* Points to group, not specific button */}
<ContentArea id="content-area" />
</Focusable>
<Focusable nextFocusUp="content-area">
<NavigationBar id="nav-bar" /> {/* Pass id as prop */}
</Focusable>
</FocusableGroup>When focus moves to a FocusableGroup, the system selects the element to focus as follows:
- If a
FocusablehashasPreferredFocus={true}, the system selects the focusable marked as preferred. - If the group has
rememberLastFocus={true}, focus returns to the last focused element in that group. - Otherwise, the system focuses the first focusable element in the group.
Group-level navigation provides the following benefits:
- Component isolation: shared components do not expose their internal focus structure.
- Flexible focus strategy: groups can change their preferred focus without affecting the components that reference them.
- Simplified navigation: parent components only need to know group IDs, not internal element IDs.
Group-level navigation has the following capabilities:
- Focus can jump between different
FocusableGroupcomponents. - Navigation references work across group boundaries when the groups are within the same parent.
- Each group can maintain its own
rememberLastFocusbehavior. - The system supports complex nested hierarchies.
The declarative approach provides the following benefits:
- The render thread handles focus navigation entirely; therefore, navigation does not have any round-trip overhead.
- The approach matches the declarative component style of RSG TypeScript.
- The approach provides better performance and responsiveness than the round-trip method.
Receiving notifications on focus change
To update app state, trigger animations, or run other business logic, the program thread needs to know when focus moves within FocusableGroup components. This section describes the OnFocusedChild event, the sequence of events when focus changes, and example uses.
Using the OnFocusedChild event
OnFocusedChild eventAll SceneGraph nodes can fire an OnFocusedChild event when one of their immediate children (or the node itself) gains or loses focus. SceneGraph calls all OnFocusedChild handlers that are on the focus path. This event is part of the built-in SceneGraph focus management system; RSG TypeScript does not introduce it.
See focusable-group-track-component-focus.skill.md for focused-component signal patterns based on this event.
The SceneGraph onFocusedChild event can detect focus changes only for the component itself or its immediate children. The event cannot detect focus changes in deeper nested components.
You can add onFocusedChild to any SceneGraph component (including FocusableGroup). If you want to know the exact component on which focus changed, you should place onFocusedChild on Focusable, for the following reasons:
- A
FocusableGroupcontainsFocusablecomponents as immediate children. - The
Focusablecomponents contain the actual focusable elements (for example, Button) as direct children. - When a Button gains focus, only its immediate parent (
Focusable) can detect the change. - A
FocusableGroupwithonFocusedChildshows only focus changes betweenFocusablecomponents, not the actual focused elements within them.
The following example compares the two placement options:
// Option 1: On FocusableGroup (only shows Focusable-level changes)
<FocusableGroup onFocusedChild={(focusableId) => {
// Fires when moving between Focusable components
// focusableId would be null as Focusable IDs are empty, not Button IDs
}}>
<Focusable nextFocusDown="button2">
<button id="button1" text="Button 1" />
</Focusable>
<Focusable nextFocusUp="button1">
<button id="button2" text="Button 2" />
</Focusable>
</FocusableGroup>
// Option 2: On Focusable (shows actual focused element changes)
<FocusableGroup>
<Focusable nextFocusDown="button2" onFocusedChild={(buttonId) => {
// Fires when button1 gains or loses focus
// buttonId would be "button1" or null
}}>
<button id="button1" text="Button 1" />
</Focusable>
<Focusable nextFocusUp="button1" onFocusedChild={(buttonId) => {
// Fires when button2 gains or loses focus
// buttonId would be "button2" or null
}}>
<button id="button2" text="Button 2" />
</Focusable>
</FocusableGroup>The Focusable component is a wrapper that holds navigation props (nextFocusUp, nextFocusDown, and so on). It does not provide special focus event handling; all SceneGraph nodes handle focus events by default. If each SceneGraph primitive component (Button, Label, and so on) had these navigation props built in, you would not need the Focusable wrapper. In that case, you could place onFocusedChild events directly on the primitive components or on the FocusableGroup.
The following example shows the current architecture:
<Focusable nextFocusRight="button1" onFocusedChild={handler}>
<button id="button0" text="Button 0" />
</Focusable>The following example shows how the same code would look if the primitive component held the navigation props:
<button
id="button0"
text="Button 0"
nextFocusRight="button1"
onFocusedChild={handler}
/>The following example places an onFocusedChild handler on each Focusable in a navigation group:
<FocusableGroup id="navigation-group">
<Focusable onFocusedChild={(focusedChildId: string) => {
if (focusedChildId) {
console.log("Home button focused:", focusedChildId);
setCurrentFocusedItem(focusedChildId);
} else {
console.log("Home button lost focus");
setCurrentFocusedItem(null);
}
}}>
<button id="home" text="Home" />
</Focusable>
<Focusable onFocusedChild={(focusedChildId: string) => {
if (focusedChildId) {
console.log("Search button focused:", focusedChildId);
setCurrentFocusedItem(focusedChildId);
} else {
console.log("Search button lost focus");
setCurrentFocusedItem(null);
}
}}>
<button id="search" text="Search" />
</Focusable>
<Focusable onFocusedChild={(focusedChildId: string) => {
if (focusedChildId) {
console.log("Settings button focused:", focusedChildId);
setCurrentFocusedItem(focusedChildId);
} else {
console.log("Settings button lost focus");
setCurrentFocusedItem(null);
}
}}>
<button id="settings" text="Settings" />
</Focusable>
</FocusableGroup>You must place the onFocusedChild event on the Focusable component, not the FocusableGroup. SceneGraph calls the handler both when focus is gained and when focus is lost:
- When focus is gained,
focusedChildIdcontains the ID of the focused element (or an empty string""if the element does not have an ID). - When focus is lost,
focusedChildIdis alwaysnull.
Understanding the event flow
This section describes the sequence of events when focus changes within a Focusable component. When focus is gained, the sequence is:
- The user navigates to a focusable element on the render thread.
- The focused element triggers the SceneGraph focus event system.
- The parent
Focusablefires anOnFocusedChildevent with the focused child's ID. - The event handler runs on the program thread with the element ID.
- Your app can update reactive state, trigger effects, and so on.
When focus is lost, the sequence is:
- The user navigates away from the focusable element.
- The element loses focus, which triggers the SceneGraph focus event system.
- The parent
Focusablefires anOnFocusedChildevent with anullvalue. - The event handler runs on the program thread with
null. - Your app can clean up state, hide related UI, and so on.
Using focus notifications
You can use the onFocusedChild event to synchronize state, track analytics, and update the UI conditionally. The following example synchronizes state with the focused section:
const [currentSection, setCurrentSection] = createSignal<string | null>('home');
<Focusable onFocusedChild={(id) => {
if (id) {
setCurrentSection(id);
} else {
setCurrentSection(null); // Focus lost
}
}}>
<button id="home" text="Home" />
</Focusable>The following example tracks focus changes for analytics:
<Focusable onFocusedChild={(id) => {
if (id) {
analytics.track('focus_gained', { element: id });
} else {
analytics.track('focus_lost', { element: 'home' });
}
}}>
<button id="home" text="Home" />
</Focusable>The following example shows or hides details depending on which button has focus:
const [showDetails, setShowDetails] = createSignal(false);
<Focusable onFocusedChild={(id) => {
if (id === 'settings') {
setShowDetails(true); // Show details when settings focused
} else {
setShowDetails(false); // Hide details when focus lost
}
}}>
<button id="settings" text="Settings" />
</Focusable>Using the onFocusedChild event provides the following benefits:
- Automatic notification: you do not need to track focus manually.
- SceneGraph integration: the event uses the existing SceneGraph focus event system.
- Program thread awareness: the program thread can update state in response to focus changes.
- Performance: an event-driven approach is more efficient than polling the focus state.
Avoiding common pitfalls
This section describes two pitfalls that cause focus to jump unexpectedly and a best practice for managing focus state dynamically.
Avoiding component recreation with gainFocus
Component recreation causes the focus system to lose track of the current focus state. When inefficient reactive patterns recreate components that have gainFocus props, the SDK reapplies the gainFocus props; therefore, the props override the current focus state and focus jumps back to the initial position.
State changes alone do not cause focus issues. The problem occurs only when state changes recreate or re-render components.
The following example shows a problematic pattern. The For component creates new Button components on each state update:
const ButtonRow = (props) => {
const focusIdx = () => props.focusIdx || 0;
// Problem: Using For with reactive array creates new components on each render
return (
<FocusableGroup>
<For each={props.buttons}>
{(buttonData, index) => {
// Each state update recreates all Button components
// This causes focus to be lost and re-applied based on gainFocus
return (
<Focusable>
<button
gainFocus={focusIdx() === index()} // Problem: Applied to recreated components
text={buttonData}
id={`button-${index()}`}
/>
</Focusable>
);
}}
</For>
</FocusableGroup>
);
};
// When props.focusIdx changes → components recreated → focus jumps to gainFocus=true buttonYou should use a stable component structure. The following example creates a stable array of indices that changes only when the length changes:
// Stable component structure
const ButtonRow = (props) => {
const focusIdx = () => props.focusIdx || 0;
const buttonsLength = createMemo(() => props.buttons.length);
// Create stable array of indices that only changes when length changes
const buttonIndices = createMemo(() =>
Array.from({ length: buttonsLength() }, (_, i) => i)
);
return (
<FocusableGroup>
<For each={buttonIndices()}>
{(index) => {
// Get button data reactively but do not recreate the button structure
const buttonData = () => props.buttons[index];
return (
<Focusable>
<button
gainFocus={focusIdx() === index} // Safe: Components are not recreated
text={buttonData()}
id={`button-${index}`}
/>
</Focusable>
);
}}
</For>
</FocusableGroup>
);
};The following solutions avoid component recreation:
- You can use a stable component structure (stable keys and structure) to prevent unnecessary component recreation:
// Use stable indices instead of reactive array iteration
const buttonIndices = createMemo(() => Array.from({ length: props.buttons.length }, (_, i) => i));- You can apply
gainFocusconditionally, only during the initial mount:
const [hasInitialized, setHasInitialized] = createSignal(false);
<button
gainFocus={!hasInitialized()}
text="Home"
/>- You should use declarative navigation: move focus with the
nextFocus*props instead of a reactivegainFocus.
You should use development tools and logging to identify when your app recreates components unnecessarily. You should look for patterns in which:
- A
Forloop iterates over reactive arrays that change frequently. - Component keys are reactive and change with state.
- Component props cause structural changes rather than only data updates.
See the focus-management examples in apps/focus-management/src/components/problematic-patterns/ for more information about these problematic patterns and their solutions.
Avoiding onFocusedChild events that cause component recreation
onFocusedChild events that cause component recreationYou should avoid using onFocusedChild events to update app state that recreates components with gainFocus props. When the app recreates those components, the SDK reapplies the gainFocus props. The reapplied props override the current focus state and cause focus to jump unexpectedly.
This pitfall applies in the following cases:
- You use
onFocusedChildto update frequently changing UI state that is tied togainFocus. - Reactive focus state causes component recreation.
- Focus state updates trigger unnecessary re-renders of the component tree.
This pitfall does not apply in the following cases:
- You use legitimate dynamic focus management scenarios (for example, adding or removing items, or conditional rendering).
- The focus state changes only when the component structure actually changes.
- Your focus logic is properly separated and does not cause component recreation.
Managing focus state dynamically
The previous pitfalls show problematic uses of reactive focus state. However, in some legitimate scenarios you need to manage focus state dynamically, especially when the component structure changes. The following scenarios are valid use cases for dynamic focus state:
- Adding or removing items: users add or remove items that affect the focusable structure.
- Conditional rendering: components appear or disappear based on user actions.
- Dynamic lists: list items are added, removed, or reordered.
- Multi-step workflows: focus needs to move to newly created elements.
The following example adds and removes buttons and adjusts the focus index:
const DynamicButtonRow = () => {
const [buttons, setButtons] = createSignal(['Button 1', 'Button 2']);
const [focusedIndex, setFocusedIndex] = createSignal(0);
const addButton = () => {
const newButtons = [...buttons(), `Button ${buttons().length + 1}`];
setButtons(newButtons);
// Focus the newly added button
setFocusedIndex(newButtons.length - 1);
};
const removeButton = (indexToRemove: number) => {
const newButtons = buttons().filter((_, i) => i !== indexToRemove);
setButtons(newButtons);
// Adjust focus if needed
const currentFocus = focusedIndex();
if (currentFocus >= newButtons.length) {
// Focus was on removed item or beyond, focus last item
setFocusedIndex(Math.max(0, newButtons.length - 1));
} else if (currentFocus > indexToRemove) {
// Focus was after removed item, shift focus index down
setFocusedIndex(currentFocus - 1);
}
// If focus was before removed item, keep same index
};
return (
<FocusableGroup>
<For each={buttons()}>
{(buttonText, index) => (
<Focusable>
<button
gainFocus={focusedIndex() === index()}
text={buttonText}
onButtonSelected={() => {
if (buttonText === 'Add Button') {
addButton();
} else {
removeButton(index());
}
}}
onFocusedChild={(id) => {
if (id) setFocusedIndex(index());
}}
/>
</Focusable>
)}
</For>
<Focusable>
<button
text="Add Button"
onButtonSelected={addButton}
/>
</Focusable>
</FocusableGroup>
);
};The following best practices apply to dynamic focus management:
- You should use a stable component structure when possible. Even with dynamic content, maintain stable component keys and structure:
// Stable structure with dynamic content
const buttonsLength = createMemo(() => buttons().length);
const buttonIndices = createMemo(() =>
Array.from({ length: buttonsLength() }, (_, i) => i)
);
<For each={buttonIndices()}>
{(index) => {
const buttonData = () => buttons()[index];
return buttonData() ? (
<Focusable>
<button
gainFocus={focusedIndex() === index}
text={buttonData()}
id={`button-${index}`}
/>
</Focusable>
) : null;
}}
</For>- You should separate focus logic from business logic by keeping focus state management separate from other reactive state:
// Separate signals for different concerns
const [items, setItems] = createSignal(initialItems); // Business data
const [focusedIndex, setFocusedIndex] = createSignal(0); // Focus state
const [uiState, setUIState] = createSignal("loading"); // UI state
// Only focusedIndex affects gainFocus, not other state changes- You should handle cases where the focus index becomes invalid:
const safeFocusIndex = createMemo(() => {
const current = focusedIndex();
const maxIndex = buttons().length - 1;
return Math.min(Math.max(0, current), maxIndex);
});
<button gainFocus={safeFocusIndex() === index()} />- You should use
onFocusedChildto keep your focus state in sync with the actual focus:
<Focusable onFocusedChild={(id) => {
if (id) {
// Update focus state when user navigates manually
setFocusedIndex(index());
}
}}>
<button gainFocus={focusedIndex() === index()} />
</Focusable>You can use dynamic focus state in the following cases:
- The component structure changes (for example, adding or removing items).
- You render components conditionally based on user actions.
- Focus needs to move to newly created elements.
- Multi-step workflows have dynamic content.
You should avoid dynamic focus state in the following cases:
- Focus is tied to frequently changing UI state.
- Focus changes on every render because of reactive dependencies.
- You use focus state for logic that is not related to focus.
- Reactive focus state does not correspond to actual structural changes.
Related documentation
FocusableGroupexample: Describes the example app that demonstrates declarative SceneGraph-native focus navigation withFocusableandFocusableGroup.FocusableGroup: Includes the generated reference for theFocusableGroupcomponent.Focusable: Includes the generated reference for theFocusablecomponent.- Focus boundary: Describes the TypeScript-side virtual focus system that you should use for new TypeScript components.
Updated about 6 hours ago