Modal boundary
The ModalBoundary component is a higher-order component (a component that wraps other components) with a companion useModal hook. Together, they encapsulate the custom-dialog focus pattern that is built on FocusBoundary.
To use them, you call useModal to get the open, close, isOpen, and focusId members, render ModalBoundary as a sibling of your content, and pass the dialog content as its children.
They remove the roughly 30 lines of boilerplate that a custom (primitive-based) dialog normally requires: stable focus refs, a conditional Show wrapper, a scrim, onUnhandledKey dismiss wiring, and setFocus calls that transfer focus.
Both are exported from
@roku-sdk/components. The implementation lives in@roku-sdk/navigationand@roku-sdk/componentsre-exports it; therefore, existing imports keep working.
When to use
You can use ModalBoundary for custom dialogs and overlays that you build from primitives (group, rectangle, label, and Poster), where you want TypeScript-side virtual focus. For portable themed OK and Cancel dialogs that you build from the frozen property subset, you can use StandardDialogNext from @roku-sdk/components/standard-dialog-next instead of StandardDialog. On device-only targets, StandardDialog still manages SceneGraph focus natively and does not need ModalBoundary.
The following table lists the component to use for each scenario:
| Scenario | Use |
|---|---|
| Custom primitive dialog or overlay with TypeScript virtual focus | ModalBoundary + useModal |
| Native, OS-themed dialog (title, content, and buttons) | StandardDialog (Modal XML bridge) |
| Non-modal focus regions on a screen | Plain FocusBoundary |
Quick start
This section shows how to use ModalBoundary for a dialog with focusable children and for a dialog without focusable children.
Case 1: Dialog with focusable children (buttons)
The children own the focus targets. You can pass initialFocus to direct focus to the default button when the dialog opens. The ModalBoundary component does not add a dummy focusable. The dialog dismisses through button onSelect callbacks, dismissKeys, or both.
The following example opens a dialog from a menu item:
import { ModalBoundary, useModal } from "@roku-sdk/components";
import { FocusBoundary, useFocusable } from "@roku-sdk/navigation";
const confirmRef: object = {};
const DialogContent = () => {
const confirm = useModal();
return (
<>
{/* content branch */}
<FocusBoundary focusId={listRef} initialFocus={item0Ref} navigation="vertical" rememberLastFocus>
<MenuItem focusId={item0Ref} label="Open dialog" onSelect={confirm.open} />
</FocusBoundary>
{/* modal sibling */}
<ModalBoundary
show={confirm.isOpen()}
focusId={confirm.focusId}
initialFocus={confirmRef}
onDismiss={confirm.close}
dismissKeys={["back"]}
dim="0x000000AA"
navigation="horizontal"
>
<DialogButton focusId={confirmRef} label="OK" onSelect={confirm.close} />
<DialogButton label="Cancel" onSelect={confirm.close} />
</ModalBoundary>
</>
);
};Case 2: Dialog without focusable children (info / help overlay)
The dialog does not have any focusable children, only visual content. You can omit initialFocus; ModalBoundary injects a hidden dummy focusable automatically so that the inner boundary has a focus target. All interaction goes through dismissKeys, which map to onUnhandledKey.
The following example shows a help overlay:
const help = useModal();
<ModalBoundary
show={help.isOpen()}
focusId={help.focusId}
onDismiss={help.close}
dismissKeys={["back", "options"]}
dim
>
<label text="Help content..." />
</ModalBoundary>Dummy-focus heuristic
The ModalBoundary component decides whether to inject a hidden dummy focusable based solely on initialFocus, as the following table describes:
initialFocus | Behavior |
|---|---|
| Provided | Children own the focus targets, and ModalBoundary does not inject a dummy. You must register the initialFocus ref with a child useFocusable (for example, a button's focusId). |
| Omitted | ModalBoundary injects a hidden, zero-size dummy focusable and uses it as the inner boundary's initialFocus. All interaction happens through dismissKeys. |
As a result, info dialogs do not need any ref setup, and button dialogs keep full control.
Scrim / dim
The dim and scrim props control the semi-transparent overlay that ModalBoundary renders behind the dialog content (over the rest of the screen), which makes the modal appear in the foreground. They are visual only: they do not handle focus or block input (dismissKeys handles dismissal).
The following table lists the values that you can set:
| Prop | Effect |
|---|---|
dim (boolean true) | Default full-screen scrim, color 0x000000AA |
dim="0x000000CC" | Full-screen scrim with a custom color or opacity |
dim omitted or false | No scrim |
scrim={() => <...>} | Custom overlay (for example, blur, gradient, or animation) that overrides dim |
The following example uses a custom scrim:
// Custom scrim (overrides dim):
<ModalBoundary
scrim={() => (
<>
<rectangle width={1920} height={1080} color="0x000000CC" />
<poster uri={blurBgUri} width={1920} height={1080} opacity={0.5} />
</>
)}
>useModal hook
The useModal hook returns the members that open and close a modal and that connect it to ModalBoundary. You can also pass an existing ref, as in the following example:
const { open, close, isOpen, focusId } = useModal(); // or useModal(existingRef)The following table describes the members:
| Member | Description |
|---|---|
open() | Sets show to true and transfers focus to the modal boundary with setFocus(focusId) (batched: show first, then focus). |
close() | Clears show and, in the same batch, calls setFocus on whatever was focused when open() ran. If that node unmounted while the modal was open, close() does not restore focus; you should set focus again yourself. |
isOpen | Provides a reactive accessor that you pass to show (for example, <ModalBoundary show={isOpen()} />). |
focusId | Provides the stable focus identity that you pass to <ModalBoundary focusId={focusId} />. The hook generates it internally if you do not supply one. |
Constraints
The following constraints apply when you use ModalBoundary and useModal:
-
Ancestor
FocusBoundaryrequired. TheuseModalhook callsuseFocusActions(), which throws an error if the hook does not have an ancestorFocusBoundary. -
Placement precision. The
setFocusfunction resolves against the nearestFocusBoundary, and the inner boundary ofModalBoundaryregisters with that nearest boundary underfocusId; therefore, you must calluseModalwhere the nearest boundary is the common ancestor of both the content branch and theModalBoundarysibling (not inside the content branch's own nestedFocusBoundary, and not insideModalBoundary).The following example calls
useModalin a valid place:<FocusBoundary focusId={outerRef} initialFocus={listRef}> {/* useModal() is called here: the nearest boundary is `outerRef` */} <DialogContent /> </FocusBoundary> -
Content-branch focus restoration (your responsibility). You should wrap the navigable content in
<FocusBoundary rememberLastFocus>so that the cursor returns to its prior position after the dialog is dismissed. -
focusIdsharing. You must passuseModal().focusIdto<ModalBoundary focusId={...}>. The two must be the same object so thatopen()can target the modal boundary.
Comparison to the manual pattern
The manual pattern requires the following for each dialog (the focus-boundary example app's focus-boundary-custom-dialog-example.tsx file shows an example):
- A stable
dialogBoundaryRefand any dismiss refs - A parent component calling
useFocusActions().setFocus(dialogBoundaryRef)to enter - A
createSignalforshowDialog - A
<Show when={showDialog()} fallback={<group />}>wrapper - A full-screen scrim
rectangle - A nested
<FocusBoundary onUnhandledKey={{ back: ... }}>
The ModalBoundary component and the useModal hook replace all of that with two calls, as in the following example:
const modal = useModal();
// ...
<ModalBoundary show={modal.isOpen()} focusId={modal.focusId} onDismiss={modal.close} dim>
{/* only the visual content */}
</ModalBoundary>How it works
The following list describes how ModalBoundary works:
- The
ModalBoundarycomponent renders<Show when={show} fallback={<group />}>(the fallback avoids SolidJS unmount edge cases), an optional scrim, and then an innerFocusBoundary(focusId={focusId}) that wraps the children. - The
ModalBoundarycomponent mapsdismissKeys(default["back"]) to the inner boundary'sonUnhandledKeywithonPress; as a result, each key firesonDismisson key-down after the focused child and directional navigation decline the key. - The
open()function recordsfocusedId()before moving focus into the modal. Onclose(), the modal subtree unmounts, andModalBoundaryhands focus back to that recorded node withsetFocus, batched with the hide. A dismiss path that also callssetFocusis redundant and can conflict with the restore; you should either remove it or callsetIsOpendirectly instead ofclose().
Related documentation
- Focus boundary: Describes the underlying virtual focus system,
useFocusActions().setFocus, andonPress. - Focus management skill: Describes how to choose a focus pattern.
Updated about 5 hours ago