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.
Modal Stacking¶
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
initialValueon mount - Calls
onChangesynchronously 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¶
- Add to
ModalMapinsrc/types/modal.ts:
export type ModalMap = {
// ... existing entries
myNewModal: Omit<MyNewModalProps, "open" | "onOpenChange">;
};
-
Create the component in
src/components/modal/(or a feature folder). The component receivesopen: booleanandonOpenChange: (open: boolean) => voidinjected by the outlet, plus your custom props. -
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.