Modal
Modal primitive. Located at apps/web/components/ui/modal.tsx. Composed of four parts.
Usage
<Modal open={open} onClose={onClose} className="max-w-[520px]">
<ModalHeader>Add member</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<Input label="Full name" ... />
<Input label="Email" ... />
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Create</Button>
</ModalFooter>
</Modal>
Size presets
By default, modal is max-w-[480px]. Override via className:
| Size | Use |
|---|---|
| max-w-[400px] | Simple confirm dialogs |
| max-w-[520px] | Forms (add member, invite) |
| max-w-[620px] | Multi-field settings |
| max-w-[820px] | Side-sidebar layouts (Project Settings) |
Height: auto-fit by default. For multi-section modals, add h-[600px] max-h-[85vh] flex flex-col to the Modal className and make each section flex-1 min-h-0 overflow-auto.
ModalHeader
<ModalHeader>Add member</ModalHeader>
Renders px-6 pt-6 pb-2 text-base font-semibold. Close X in the top-right is automatic.
ModalBody
<ModalBody className="flex flex-col gap-4">
{/* form fields */}
</ModalBody>
Default padding px-6 pb-4. Override via className if needed (e.g., dense tables).
ModalFooter
<ModalFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save}>Save</Button>
</ModalFooter>
Primary action on the right. Cancel on the left (ghost variant). Separator line above.
Error state
Alert banner goes inside ModalBody at the top:
<ModalBody className="flex flex-col gap-4">
{error && (
<div className="px-3 py-2 text-sm text-error bg-error/10 border border-error/20 rounded-[var(--radius-sm)]">
{error}
</div>
)}
{/* fields */}
</ModalBody>
Closing behavior
- Click backdrop → fires
onClose - Press Escape → fires
onClose - Click close X → fires
onClose
Modal does NOT reset internal state on close — the parent owns that. When you pass in fresh state each time, clear it via a reset() function.
Don't
- Don't pass
onClose={() => setOpen(false)}directly inside — use ahandleClosethat also resets form state if needed - Don't nest modals — use a second screen within the same modal (e.g.,
stepstate) instead - Don't add custom close behavior (disable backdrop click etc.) without reason — users expect standard dismiss