Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/RtlZeroMemory/Rezi/llms.txt

Use this file to discover all available pages before exploring further.

Modal Dialog Patterns

Patterns for creating modal dialogs, managing modal stacks, and building confirmation flows.

Problem

You need to display:
  • Confirmation dialogs for destructive actions
  • Forms or complex content in modal overlays
  • Multiple modals stacked on top of each other
  • Proper focus management and keyboard navigation

Solution

Use ui.modal() for overlays, manage visibility with state, and use useModalStack for multiple modals.

Basic Confirmation Dialog

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

type State = {
  items: Array<{ id: string; name: string }>;
  confirmDeleteId: string | null;
};

const app = createNodeApp<State>({
  initialState: {
    items: [
      { id: "1", name: "Project Alpha" },
      { id: "2", name: "Project Beta" },
      { id: "3", name: "Project Gamma" },
    ],
    confirmDeleteId: null,
  },
});

app.view((state) => {
  const confirmItem = state.confirmDeleteId
    ? state.items.find((i) => i.id === state.confirmDeleteId)
    : null;

  return ui.layers([
    // Main content
    ui.page({ p: 1 }, [
      ui.panel("Projects", [
        ui.column({ gap: 1 }, [
          ...state.items.map((item) =>
            ui.row({ key: item.id, gap: 2, justify: "between" }, [
              ui.text(item.name),
              ui.button({
                id: `delete-${item.id}`,
                label: "Delete",
                intent: "danger",
                onPress: () =>
                  app.update((s) => ({ ...s, confirmDeleteId: item.id })),
              }),
            ])
          ),
        ]),
      ]),
    ]),

    // Modal overlay (conditional)
    confirmItem &&
      ui.modal({
        id: "confirm-delete",
        title: "Confirm Deletion",
        width: 50,
        backdrop: "dim",
        content: ui.column({ gap: 1 }, [
          ui.text(`Delete "${confirmItem.name}"?`),
          ui.text("This action cannot be undone.", { variant: "caption" }),
        ]),
        actions: [
          ui.button({
            id: "cancel",
            label: "Cancel",
            intent: "secondary",
            onPress: () => app.update((s) => ({ ...s, confirmDeleteId: null })),
          }),
          ui.button({
            id: "confirm",
            label: "Delete",
            intent: "danger",
            onPress: () =>
              app.update((s) => ({
                ...s,
                items: s.items.filter((it) => it.id !== s.confirmDeleteId),
                confirmDeleteId: null,
              })),
          }),
        ],
        onClose: () => app.update((s) => ({ ...s, confirmDeleteId: null })),
        returnFocusTo: confirmItem ? `delete-${confirmItem.id}` : undefined,
      }),
  ]);
});

app.keys({
  "ctrl+c": () => app.stop(),
  q: () => app.stop(),
  escape: () => app.update((s) => ({ ...s, confirmDeleteId: null })),
});

await app.start();
Key elements:
  • ui.layers([...]) - Renders main content with modal on top
  • backdrop: "dim" - Darkens background content
  • onClose - Handles Escape key and close button
  • returnFocusTo - Restores focus to triggering element after close
import { defineWidget, ui, useForm } from "@rezi-ui/core";

type ModalState = { showCreateUser: boolean };
type UserFormValues = { name: string; email: string; role: string };

const App = defineWidget<ModalState>((ctx) => {
  const [state, setState] = ctx.useState<ModalState>(() => ({
    showCreateUser: false,
  }));

  const form = useForm<UserFormValues>(ctx, {
    initialValues: { name: "", email: "", role: "user" },
    validate: (v) => ({
      name: v.name ? undefined : "Required",
      email: v.email.includes("@") ? undefined : "Invalid email",
    }),
    onSubmit: async (values) => {
      console.log("Create user:", values);
      setState({ showCreateUser: false });
      form.reset();
    },
  });

  return ui.layers([
    ui.page({ p: 1 }, [
      ui.panel("Users", [
        ui.button({
          id: "create-user",
          label: "Create User",
          intent: "primary",
          onPress: () => setState({ showCreateUser: true }),
        }),
      ]),
    ]),

    state.showCreateUser &&
      ui.modal({
        id: "create-user-modal",
        title: "Create New User",
        width: 60,
        backdrop: "dim",
        content: ui.form([
          form.field("name", { label: "Full Name", required: true }),
          form.field("email", { label: "Email", required: true }),
          ui.field({
            label: "Role",
            children: ui.select({
              ...form.bind("role"),
              options: [
                { value: "user", label: "User" },
                { value: "admin", label: "Admin" },
              ],
            }),
          }),
        ]),
        actions: [
          ui.button({
            id: "cancel",
            label: "Cancel",
            intent: "secondary",
            onPress: () => {
              setState({ showCreateUser: false });
              form.reset();
            },
          }),
          ui.button({
            id: "submit",
            label: "Create",
            intent: "primary",
            disabled: !form.isValid,
            onPress: form.handleSubmit,
          }),
        ],
        onClose: () => {
          setState({ showCreateUser: false });
          form.reset();
        },
        initialFocus: "name",
      }),
  ]);
});
For applications with multiple overlapping modals, use useModalStack:
import { defineWidget, ui, useModalStack } from "@rezi-ui/core";

const App = defineWidget<void>((ctx) => {
  const modals = useModalStack(ctx);

  const showConfirmDelete = (itemId: string) => {
    modals.push(`delete-${itemId}`, {
      title: "Confirm Deletion",
      content: ui.text(`Delete item ${itemId}?`),
      actions: [
        ui.button({
          id: "cancel",
          label: "Cancel",
          onPress: () => modals.pop(),
        }),
        ui.button({
          id: "confirm",
          label: "Delete",
          intent: "danger",
          onPress: () => {
            console.log("Deleted", itemId);
            modals.pop();
          },
        }),
      ],
    });
  };

  const showSettings = () => {
    modals.push("settings", {
      title: "Settings",
      content: ui.column({ gap: 1 }, [
        ui.text("Application Settings"),
        ui.button({
          id: "reset",
          label: "Reset to Defaults",
          intent: "danger",
          onPress: () => showConfirmDelete("settings-reset"),
        }),
      ]),
      actions: [
        ui.button({
          id: "close",
          label: "Close",
          onPress: () => modals.pop(),
        }),
      ],
    });
  };

  return ui.layers([
    ui.page({ p: 1 }, [
      ui.panel("App", [
        ui.button({
          id: "settings",
          label: "Open Settings",
          onPress: showSettings,
        }),
      ]),
    ]),

    // Render modal stack
    ...modals.render(),
  ]);
});
Modal stack features:
  • LIFO ordering - Last opened modal is on top
  • Automatic focus management - Focus moves between modals
  • modals.push(id, props) - Add modal to stack
  • modals.pop() - Remove top modal
  • modals.clear() - Close all modals
  • modals.current() - Get ID of top modal
  • modals.size - Number of open modals

Dialog Helper Functions

Rezi provides pre-built dialog patterns:

Confirmation Dialog

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

app.view((state) => {
  return ui.layers([
    ui.page({ p: 1 }, [...]),

    state.showConfirm &&
      confirmDialog({
        id: "confirm",
        title: "Confirm Action",
        message: "Are you sure you want to proceed?",
        confirmLabel: "Yes, proceed",
        cancelLabel: "Cancel",
        intent: "primary",
        onConfirm: () => {
          console.log("Confirmed");
          app.update((s) => ({ ...s, showConfirm: false }));
        },
        onCancel: () => app.update((s) => ({ ...s, showConfirm: false })),
      }),
  ]);
});

Alert Dialog

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

alertDialog({
  id: "alert",
  title: "Operation Complete",
  message: "Your changes have been saved successfully.",
  buttonLabel: "OK",
  onClose: () => app.update((s) => ({ ...s, showAlert: false })),
});

Prompt Dialog

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

promptDialog({
  id: "prompt",
  title: "Enter Name",
  message: "Please enter a name for this item:",
  placeholder: "Item name",
  defaultValue: "",
  confirmLabel: "Save",
  cancelLabel: "Cancel",
  onConfirm: (value) => {
    console.log("Entered:", value);
    app.update((s) => ({ ...s, showPrompt: false }));
  },
  onCancel: () => app.update((s) => ({ ...s, showPrompt: false })),
});

Focus Management

Initial Focus

Set which element receives focus when modal opens:
ui.modal({
  id: "modal",
  title: "Modal Title",
  content: ui.form([
    ui.input({ id: "name", value: "" }),
    ui.input({ id: "email", value: "" }),
  ]),
  actions: [
    ui.button({ id: "cancel", label: "Cancel" }),
    ui.button({ id: "submit", label: "Submit" }),
  ],
  initialFocus: "name", // Focus this input on open
});

Return Focus

Restore focus to triggering element after modal closes:
ui.button({
  id: "open-modal",
  label: "Open",
  onPress: () => setState({ showModal: true }),
}),

state.showModal &&
  ui.modal({
    id: "modal",
    title: "Modal",
    content: ui.text("Content"),
    actions: [ui.button({ id: "close", label: "Close" })],
    returnFocusTo: "open-modal", // Return focus here on close
  });

Focus Trap

Modals automatically trap focus - Tab/Shift+Tab cycles only through modal elements:
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.column({ gap: 1 }, [
    ui.input({ id: "input1", value: "" }),
    ui.input({ id: "input2", value: "" }),
  ]),
  actions: [
    ui.button({ id: "cancel", label: "Cancel" }),
    ui.button({ id: "submit", label: "Submit" }),
  ],
  // Focus cycles: input1 → input2 → cancel → submit → input1
});

Backdrop Styles

ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Content"),
  backdrop: "blur", // "none" | "dim" | "blur"
  actions: [...],
});
  • none - No backdrop (main content fully visible)
  • dim - Semi-transparent dark overlay (default)
  • blur - Blurred background effect
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Content"),
  width: 60, // Fixed width in cells
  minWidth: 40, // Minimum width
  maxWidth: 80, // Maximum width
  height: 20, // Fixed height in rows
  minHeight: 10, // Minimum height
  maxHeight: 30, // Maximum height
  actions: [...],
});

Keyboard Handling

Global Escape Handler

app.keys({
  escape: () => {
    app.update((s) => ({
      ...s,
      showModal: false,
      confirmDeleteId: null,
      // Clear all modal state
    }));
  },
});
ui.modal({
  id: "modal",
  title: "Modal",
  content: ui.text("Press Ctrl+S to save"),
  actions: [...],
  onClose: () => setState({ showModal: false }),
});

// In app keys
app.keys({
  "ctrl+s": (ctx) => {
    if (ctx.state.showModal) {
      // Handle save while modal is open
      console.log("Save from modal");
    }
  },
});

Common Patterns

Confirmation Before Navigation

type State = {
  currentPage: string;
  hasUnsavedChanges: boolean;
  pendingNavigation: string | null;
};

function requestNavigate(targetPage: string) {
  app.update((s) => {
    if (s.hasUnsavedChanges) {
      return { ...s, pendingNavigation: targetPage };
    }
    return { ...s, currentPage: targetPage };
  });
}

app.view((state) => {
  return ui.layers([
    ui.page({ p: 1 }, [...]),

    state.pendingNavigation &&
      ui.modal({
        id: "unsaved-changes",
        title: "Unsaved Changes",
        content: ui.text("You have unsaved changes. Discard them?"),
        actions: [
          ui.button({
            id: "cancel",
            label: "Cancel",
            onPress: () => app.update((s) => ({ ...s, pendingNavigation: null })),
          }),
          ui.button({
            id: "discard",
            label: "Discard",
            intent: "danger",
            onPress: () =>
              app.update((s) => ({
                ...s,
                currentPage: s.pendingNavigation!,
                pendingNavigation: null,
                hasUnsavedChanges: false,
              })),
          }),
        ],
        onClose: () => app.update((s) => ({ ...s, pendingNavigation: null })),
      }),
  ]);
});

Multi-Step Modal Flow

type WizardState = {
  showWizard: boolean;
  step: number;
};

app.view((state) => {
  return ui.layers([
    ui.page({ p: 1 }, [...]),

    state.showWizard &&
      ui.modal({
        id: "wizard",
        title: `Setup: Step ${state.step + 1} of 3`,
        content:
          state.step === 0
            ? ui.text("Step 1 content")
            : state.step === 1
              ? ui.text("Step 2 content")
              : ui.text("Step 3 content"),
        actions: [
          state.step > 0 &&
            ui.button({
              id: "back",
              label: "Back",
              onPress: () => app.update((s) => ({ ...s, step: s.step - 1 })),
            }),
          ui.button({
            id: state.step === 2 ? "finish" : "next",
            label: state.step === 2 ? "Finish" : "Next",
            intent: "primary",
            onPress: () =>
              app.update((s) =>
                s.step === 2
                  ? { ...s, showWizard: false, step: 0 }
                  : { ...s, step: s.step + 1 }
              ),
          }),
        ],
        onClose: () => app.update((s) => ({ ...s, showWizard: false, step: 0 })),
      }),
  ]);
});

Loading Modal

app.view((state) => {
  return ui.layers([
    ui.page({ p: 1 }, [...]),

    state.isLoading &&
      ui.modal({
        id: "loading",
        title: "Processing",
        content: ui.column({ gap: 1 }, [
          ui.spinner({ label: "Please wait..." }),
          ui.text("This may take a few moments.", { variant: "caption" }),
        ]),
        actions: [], // No actions - cannot close during load
        backdrop: "blur",
      }),
  ]);
});

Best Practices

  1. Use ui.layers([...]) - Proper layering for modals
  2. Always provide onClose - Handle Escape key
  3. Set returnFocusTo - Restore focus after close
  4. Use initialFocus for forms - Guide user to first input
  5. Disable backdrop click to close for destructive actions - Use explicit buttons
  6. Keep modals focused - Don’t nest complex UIs unnecessarily
  7. Use useModalStack for multi-modal UIs - Automatic focus/stack management
  8. Show clear action intent - Use intent prop on buttons
  9. Provide visual hierarchy - Primary action should stand out
  10. Test keyboard navigation - Tab, Shift+Tab, Escape, Enter