Skip to main content

Overview

The modal widget creates a centered overlay dialog with an optional backdrop, automatic focus management, and keyboard controls. Modals block interaction with content below and trap focus within the dialog.

Basic Usage

import { ui } from "@rezi-ui/core";
import { defineWidget } from "@rezi-ui/core";

const MyApp = defineWidget((ctx) => {
  const [showModal, setShowModal] = ctx.useState(false);

  return ui.layers([
    ui.column({ gap: 1, p: 1 }, [
      ui.button({
        id: "open-modal",
        label: "Open Modal",
        onPress: () => setShowModal(true),
      }),
    ]),
    showModal &&
      ui.modal({
        id: "my-modal",
        title: "Confirm Action",
        content: ui.text("Are you sure you want to proceed?"),
        actions: [
          ui.button({
            id: "modal-cancel",
            label: "Cancel",
            onPress: () => setShowModal(false),
          }),
          ui.button({
            id: "modal-confirm",
            label: "Confirm",
            intent: "primary",
            onPress: () => {
              // Handle confirmation
              setShowModal(false);
            },
          }),
        ],
        onClose: () => setShowModal(false),
      }),
  ]);
});

Props

id
string
required
Unique identifier for focus routing.
content
VNode
required
Main content of the modal body.
title
string
Optional title rendered in modal header.
actions
readonly VNode[]
Action buttons rendered in modal footer (typically buttons).
width
number | 'auto'
Modal width in cells, or "auto" to fit content.
height
number
Modal height in cells.
maxWidth
number
Maximum width constraint.
minWidth
number
Minimum width constraint.
minHeight
number
Minimum height constraint.

Backdrop Configuration

backdrop
ModalBackdrop
default:"'dim'"
Backdrop style or configuration:
  • "none" - No backdrop
  • "dim" - Semi-transparent overlay
  • "opaque" - Solid backdrop
  • Object with variant, pattern, foreground, background
closeOnBackdrop
boolean
default:"true"
Close when backdrop is clicked.
closeOnEscape
boolean
default:"true"
Close when ESC key is pressed.
onClose
() => void
Callback when modal should close (ESC, backdrop click, or explicit close).

Focus Management

initialFocus
string
ID of element to focus when modal opens.
returnFocusTo
string
ID of element to return focus to when modal closes.

Styling

frameStyle
OverlayFrameStyle
Frame/surface colors for modal body and border:
  • background - Surface background color
  • foreground - Default text/icon color
  • border - Border color

Keyboard Controls

KeyAction
ESCClose modal (if closeOnEscape enabled)
TabNavigate to next focusable element in modal
Shift+TabNavigate to previous focusable element
EnterActivate focused button

Backdrop Variants

Dim Backdrop (Default)

Semi-transparent overlay:
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Modal content"),
  backdrop: "dim", // Default
  onClose: closeModal,
});

Opaque Backdrop

Solid background:
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Modal content"),
  backdrop: "opaque",
  onClose: closeModal,
});

No Backdrop

Modal without backdrop overlay:
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Modal content"),
  backdrop: "none",
  onClose: closeModal,
});

Custom Backdrop

ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Modal content"),
  backdrop: {
    variant: "dim",
    pattern: "░", // Light shade
    foreground: { r: 100, g: 100, b: 150 },
    background: { r: 0, g: 0, b: 0 },
  },
  onClose: closeModal,
});

Dialog Helper

Use ui.dialog() for simple confirmation dialogs:
import { ui } from "@rezi-ui/core";

ui.dialog({
  id: "confirm-delete",
  title: "Delete Item",
  message: "Are you sure? This cannot be undone.",
  actions: [
    {
      label: "Cancel",
      onPress: () => closeDialog(),
    },
    {
      label: "Delete",
      intent: "danger",
      onPress: () => {
        performDelete();
        closeDialog();
      },
    },
  ],
  onClose: closeDialog,
});

Form Modal

import { defineWidget } from "@rezi-ui/core";

const EditUserModal = defineWidget((ctx, props: { user: User; onClose: () => void }) => {
  const [name, setName] = ctx.useState(props.user.name);
  const [email, setEmail] = ctx.useState(props.user.email);

  const handleSave = () => {
    saveUser({ ...props.user, name, email });
    props.onClose();
  };

  return ui.modal({
    id: "edit-user-modal",
    title: "Edit User",
    width: 50,
    content: ui.form([
      ui.field({
        label: "Name",
        children: ui.input("edit-name", name, {
          onInput: setName,
        }),
      }),
      ui.field({
        label: "Email",
        children: ui.input("edit-email", email, {
          onInput: setEmail,
        }),
      }),
    ]),
    actions: [
      ui.button({
        id: "edit-cancel",
        label: "Cancel",
        onPress: props.onClose,
      }),
      ui.button({
        id: "edit-save",
        label: "Save",
        intent: "primary",
        onPress: handleSave,
        disabled: !name || !email,
      }),
    ],
    initialFocus: "edit-name",
    onClose: props.onClose,
  });
});

Confirmation Modal

import { defineWidget } from "@rezi-ui/core";

const ConfirmModal = defineWidget(
  (ctx, props: { title: string; message: string; onConfirm: () => void; onCancel: () => void }) => {
    return ui.modal({
      id: "confirm-modal",
      title: props.title,
      content: ui.text(props.message),
      actions: [
        ui.button({
          id: "confirm-no",
          label: "No",
          onPress: props.onCancel,
        }),
        ui.button({
          id: "confirm-yes",
          label: "Yes",
          intent: "primary",
          onPress: props.onConfirm,
        }),
      ],
      initialFocus: "confirm-no",
      onClose: props.onCancel,
    });
  }
);

Loading Modal

import { ui } from "@rezi-ui/core";

ui.modal({
  id: "loading-modal",
  content: ui.column({ gap: 1, items: "center" }, [
    ui.spinner({ variant: "dots" }),
    ui.text("Processing..."),
  ]),
  backdrop: "opaque",
  closeOnEscape: false,
  closeOnBackdrop: false,
});

Multi-Step Modal

import { defineWidget } from "@rezi-ui/core";

const WizardModal = defineWidget((ctx, props: { onClose: () => void }) => {
  const [step, setStep] = ctx.useState(1);
  const totalSteps = 3;

  const nextStep = () => setStep(step + 1);
  const prevStep = () => setStep(step - 1);

  return ui.modal({
    id: "wizard-modal",
    title: `Step ${step} of ${totalSteps}`,
    width: 60,
    content:
      step === 1
        ? Step1Content()
        : step === 2
          ? Step2Content()
          : Step3Content(),
    actions: [
      step > 1 &&
        ui.button({
          id: "wizard-prev",
          label: "Previous",
          onPress: prevStep,
        }),
      ui.button({
        id: "wizard-cancel",
        label: "Cancel",
        onPress: props.onClose,
      }),
      ui.button({
        id: "wizard-next",
        label: step === totalSteps ? "Finish" : "Next",
        intent: "primary",
        onPress: step === totalSteps ? props.onClose : nextStep,
      }),
    ],
    onClose: props.onClose,
  });
});
Multiple modals are automatically stacked with proper z-indexing:
import { ui } from "@rezi-ui/core";

ui.layers([
  MainContent(),
  state.showModal1 && Modal1({ onClose: () => closeModal1() }),
  state.showModal2 && Modal2({ onClose: () => closeModal2() }),
]);

Custom Styling

ui.modal({
  id: "styled-modal",
  title: "Custom Modal",
  content: ui.text("Content"),
  frameStyle: {
    background: { r: 30, g: 30, b: 40 },
    foreground: { r: 220, g: 220, b: 230 },
    border: { r: 100, g: 120, b: 150 },
  },
  onClose: closeModal,
});

Best Practices

  1. Use sparingly - Modals interrupt user flow; prefer inline forms when possible
  2. Provide clear actions - Always include Cancel or Close option
  3. Focus first input - Use initialFocus to direct keyboard navigation
  4. Handle ESC key - Allow users to cancel with ESC unless critical
  5. Return focus - Use returnFocusTo to restore focus after close
  6. Size appropriately - Don’t make modals too large for terminal viewport

Accessibility

  • Focus automatically trapped within modal when open
  • ESC key closes modal (unless disabled)
  • Tab navigation cycles through modal elements
  • Focus returns to trigger element on close
  • Backdrop clicks close modal (unless disabled)
  • Dialog - Simplified confirmation dialog helper
  • Dropdown - For contextual menus
  • Layer - For custom overlay positioning
  • Toast - For non-blocking notifications

Location in Source

  • Types: packages/core/src/widgets/types.ts:1054-1087
  • Factory: packages/core/src/widgets/ui.ts:1249

Build docs developers (and LLMs) love