ilokesto

Practical guide

Name every dialog

Prefer a visible heading connected by ariaLabelledBy; connect explanatory text with ariaDescribedBy. If no visible title exists, provide ariaLabel. Use role: 'alertdialog' only for urgent decisions requiring immediate attention.

await display({
  role: 'alertdialog',
  ariaLabelledBy: 'discard-title',
  ariaDescribedBy: 'discard-description',
  render: (close) => (
    <section>
      <h2 id="discard-title">Discard changes?</h2>
      <p id="discard-description">Unsaved edits will be lost.</p>
      <button onClick={() => close(false)}>Keep editing</button>
      <button onClick={() => close(true)}>Discard</button>
    </section>
  ),
});

The inline adapter traps focus in the top modal, focuses the first control (or panel), restores prior focus, and locks body scroll. Native <dialog> handles focus containment in top-layer mode. Set autoFocus or restoreFocus to false only when your application replaces that behavior deliberately.

Both transports choose the first eligible autofocus control in DOM order. Inline Tab wrapping uses the same candidates. Negative tabIndex, hidden inputs, disabled controls (including disabled fieldsets), and controls hidden by hidden, inert, aria-hidden="true", display: none, visibility: hidden / collapse, or an ancestor's content-visibility: hidden are excluded. A visible child can override inherited visibility: hidden; opacity during the entry animation does not exclude controls. If no candidate remains, the panel or native dialog receives focus.

Native showModal() still owns top-layer keyboard containment and escapes an ancestor's inert; inert on the dialog itself or its content still applies. Candidate filtering does not make aria-hidden prevent browser keyboard focus. Use hidden or inert when content must also be unavailable to keyboard users.

Choose a transport

Use the default transport: 'inline' for consistent package-controlled positioning, backdrop, and motion. Use transport: 'top-layer' when the dialog must escape clipping and stacking contexts:

await display({
  transport: 'top-layer',
  ariaLabel: 'Account settings',
  render: (close) => <SettingsPanel onDone={() => close()} />,
});

Top-layer transport requires browser support for native <dialog> and showModal(). It does not include a polyfill.

Style the panel and backdrop

className and style target the panel or <dialog>. backdropClassName and backdropStyle target the inline backdrop; top-layer mode applies backdrop styling through its generated ::backdrop rule. Package motion uses 200 ms fade/scale animations and honors prefers-reduced-motion by removing promptly.

Use scoped and global commands intentionally

Inside the provider, prefer useModal() so ownership is visible. The module-level modal facade is useful outside React components, but requires one mounted default <ModalProvider>:

<ModalProvider><AppRoutes /></ModalProvider>;

const id = modal.open({ ariaLabel: 'Working', render: () => <Progress /> });
modal.close(id);

For multiple independent providers, pass each a distinct createOverlayStore() and use hooks inside its tree; the global facade always targets globalModalStore.

Keep render callbacks pure

Do not call hooks or run side effects inside render. Return a component that owns hooks instead: render: (close) => <EditorDialog onClose={close} />.

On this page