Skip to content

Modal System

Two patterns for opening modals: registry modals (type-safe, predefined) and inline modals (ad-hoc, no registration needed).


Two Patterns

Pattern When to use
Registry modal Reusable dialogs opened from multiple places; full TypeScript autocomplete on props
Inline modal One-off modals built at the call site; no ModalMap entry required

Registry Modals — useModal()

import { useModal } from "@/hooks/useModal";

const modal = useModal();

// Open — props are fully typed per modal ID
modal.open("deleteSubject", { subjectId: subject.id, onSuccess: refetch });

// Close one
modal.close("deleteSubject");

// Close all
modal.closeAll();

open(id, props) accepts id: keyof ModalMap, giving TypeScript autocomplete on every prop. Props are frozen at call time — see the note below.


Inline Modals — useInlineModal()

import { useInlineModal } from "@/hooks/useModal";

const modal = useInlineModal();

modal.open({
  title: "My Modal",
  mainContent: <MyForm />,
  footerRight: <Button onClick={modal.close}>Done</Button>,
});

// modal.isOpen — boolean
// modal.close() — closes this specific instance

For live-updating config (e.g. the title changes based on form state), pass a reactive config object as the first argument to useInlineModal(config). It is synced on every render.


ModalHubConfig

Layout props accepted by both useInlineModal() and ModalHub directly:

Prop Type Description
mainContent ReactNode Required. Central content area
leftSidebar ReactNode Left panel
rightSidebar ReactNode Right panel
footerLeft ReactNode Footer — left slot (≈30% width)
footerCenter ReactNode Footer — center slot (≈40% width)
footerRight ReactNode Footer — right slot (≈30% width)
additionalContent ReactNode Absolutely-positioned extras (overlays, portals)
sectionClassName string Additional class on the main section
title string Accessible dialog title
onClose () => void Called when the user dismisses the modal

ModalHub Component

Use ModalHub directly when you need imperative open/setOpen control rather than the hook API:

import { ModalHub } from "@/components/ModalHub/ModalHub";

<ModalHub
  open={open}
  setOpen={setOpen}
  mainContent={<ReviewCanvas />}
  rightSidebar={<CommentsList />}
  footerRight={<ApproveButton />}
  title="Post-Production Review"
/>

Footer proportions are fixed at 30% / 40% / 30% (left / center / right). Sidebars collapse automatically when their prop is undefined.


Modals stack in an ordered list. Only the topmost modal: - Intercepts the Escape key - Receives backdrop click events

Modals below the top go opacity-0 pointer-events-none — they remain mounted but invisible. The stack unwinds correctly when modals close.


useModalState()

For dialogs that need to report live state back to the opener (filter panels, settings dialogs):

import { useModalState } from "@/hooks/useModalState";

// Inside the modal component
const [filter, setFilter] = useModalState(initialFilter, onChange);
  • Initializes from initialValue on mount
  • Calls onChange synchronously on every update
  • Supports functional updates: setFilter(prev => ({ ...prev, page: 1 }))

Pattern — the parent passes onChange when calling modal.open(...), and reads updated values via a ref or state in the parent scope.


Props Frozen at open()

Registry modal props are captured at the moment open() is called. Callbacks close over values at that time.

// Bug — count is stale inside the modal callback
modal.open("exportDialog", { count: items.length, onConfirm: handleExport });

// Fix — use a ref for values that change after open
const countRef = useRef(items.length);
modal.open("exportDialog", {
  count: items.length,
  onConfirm: () => handleExport(countRef.current),
});

Use useModalState or useInlineModal(config) (with reactive config) to avoid this entirely.


Adding a New Registry Modal

  1. Add to ModalMap in src/types/modal.ts:
export type ModalMap = {
  // ... existing entries
  myNewModal: Omit<MyNewModalProps, "open" | "onOpenChange">;
};
  1. Create the component in src/components/modal/ (or a feature folder). The component receives open: boolean and onOpenChange: (open: boolean) => void injected by the outlet, plus your custom props.

  2. Add a lazy entry to src/components/modal/modal-registry.ts:

export const modalRegistry = {
  // ... existing entries
  myNewModal: lazy(() => import("@/components/modal/MyNewModal")),
};

Source Files

File Contents
src/types/modal.ts ModalMap, ModalHubConfig, ModalEntry types
src/hooks/useModal.ts useModal(), useInlineModal() hooks
src/hooks/useModalState.ts useModalState() hook
src/components/modal/ModalOutlet.tsx Global modal renderer — mounts the stack
src/components/modal/modal-registry.ts Lazy-loaded registry entries
src/components/modal/InlineModalShell.tsx Shared Radix shell for inline modal groups
src/components/ModalHub/ModalHub.tsx ModalHub layout component

See UI Providers for PopoverProvider, ActionMenuProvider, MediaResourceGalleryProvider, and the full ClientProviders tree.