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/navigation and @roku-sdk/components re-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:

ScenarioUse
Custom primitive dialog or overlay with TypeScript virtual focusModalBoundary + useModal
Native, OS-themed dialog (title, content, and buttons)StandardDialog (Modal XML bridge)
Non-modal focus regions on a screenPlain 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:

initialFocusBehavior
ProvidedChildren 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).
OmittedModalBoundary 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:

PropEffect
dim (boolean true)Default full-screen scrim, color 0x000000AA
dim="0x000000CC"Full-screen scrim with a custom color or opacity
dim omitted or falseNo 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:

MemberDescription
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.
isOpenProvides a reactive accessor that you pass to show (for example, <ModalBoundary show={isOpen()} />).
focusIdProvides 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 FocusBoundary required. The useModal hook calls useFocusActions(), which throws an error if the hook does not have an ancestor FocusBoundary.

  • Placement precision. The setFocus function resolves against the nearest FocusBoundary, and the inner boundary of ModalBoundary registers with that nearest boundary under focusId; therefore, you must call useModal where the nearest boundary is the common ancestor of both the content branch and the ModalBoundary sibling (not inside the content branch's own nested FocusBoundary, and not inside ModalBoundary).

    The following example calls useModal in 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.

  • focusId sharing. You must pass useModal().focusId to <ModalBoundary focusId={...}>. The two must be the same object so that open() 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 dialogBoundaryRef and any dismiss refs
  • A parent component calling useFocusActions().setFocus(dialogBoundaryRef) to enter
  • A createSignal for showDialog
  • 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 ModalBoundary component renders <Show when={show} fallback={<group />}> (the fallback avoids SolidJS unmount edge cases), an optional scrim, and then an inner FocusBoundary (focusId={focusId}) that wraps the children.
  • The ModalBoundary component maps dismissKeys (default ["back"]) to the inner boundary's onUnhandledKey with onPress; as a result, each key fires onDismiss on key-down after the focused child and directional navigation decline the key.
  • The open() function records focusedId() before moving focus into the modal. On close(), the modal subtree unmounts, and ModalBoundary hands focus back to that recorded node with setFocus, batched with the hide. A dismiss path that also calls setFocus is redundant and can conflict with the restore; you should either remove it or call setIsOpen directly instead of close().

Related documentation


Did this page help you?