Modal
The older MUI-based modal still used by settings, sessions and dashboards. Use Dialog for new work.
import { Modal } from "@/ui";Modal or Dialog
Modal and Dialog do the same job: a centred panel with a coloured top rail in blue, orange or red, a title and a close button. They grew up separately, so each has what the other lacks.
| Modal | Dialog | |
|---|---|---|
| Built on | MUI Modal | Plain markup and the design-system tokens |
| Backdrop, portal, scroll lock | Yes | No. The caller adds a backdrop. |
| Escape and click outside close it | Yes | No. The caller adds them. |
| Focus moved in, kept in, and returned | Yes | No |
role="dialog" and a name | No | No. The caller adds them. |
| Sizes | One, 384px. Every caller overrides it, usually to 500px. | 460, 650 and 800px |
| Footer | None. Every caller builds Cancel and Confirm by hand. | Built in |
| Header | 16px title with 32px side padding. 9 of the 10 callers shrink it to a 14px title with 16px padding. | 16px title with 12px side padding |
| Rail | 3px | 4px |
| Used in | 10 files: integrations settings, sessions, dashboards, message deletion and the terms of service. 2 more in _archive. | 6 files, 7 dialogs: the library (page settings, publishing, filing, previews, brand import), workflow run review and the workbook. |
Use Dialog for new work. It is the design-system dialog: it has the sizes and the built-in footer, and the newer library, workflow review and workbook screens use it. Modal callers each restyle the width, header and title and hand-build the same footer, which is what Dialog already provides. Modal also pulls in MUI for one panel.
Modal does handle focus, Escape and the backdrop properly, and Dialog does not. That is a gap to close in Dialog, not a reason to start new screens on Modal. Until then, wrap Dialog as shown on its page. When you next work on a screen that uses Modal, move it to Dialog.
There is a third dialog, Dialog in @/ui/components/OverlayDialog/OverlayDialog, with DialogHeader, DialogContent and DialogFooter. It is not exported from @/ui. It does have a backdrop, Escape, a focus trap, scroll lock and role="dialog", and the workflow, publish and schedule dialogs use it. Its name clashes with the Dialog on this site, so check the import path when you read existing code. Settings and the profile page also have three hand-written Modal.jsx files of their own.
Usage
Pass isOpen and onClose. Modal closes itself on Escape and on a click on the backdrop by calling onClose. Put the body and your own footer in children.
<Modal
isOpen={open}
onClose={close}
title="Delete session"
variant="error"
className="w-[500px]"
headerClassName="py-3 px-4"
titleClassName="text-[14px] font-medium"
>
<div className="px-4 pb-4 flex flex-col gap-2">
<p className="text-[14px] text-[var(--color-grey-600)]">
Delete <b>Pipeline by channel, Q3 review</b>? The chat and its charts will be removed.
</p>
<p className="text-[12px] text-[var(--color-grey-500)]">This cannot be undone.</p>
</div>
<div className="flex justify-between items-center py-3 px-4 border-t border-[var(--color-grey-100)]">
<Button variant="ghost" size="lg" label="Cancel" onClick={close} />
<Button variant="red" size="lg" label="Delete" onClick={remove} />
</div>
</Modal>This is how the existing delete confirmations are built, and it is the one thing Modal can do today that Dialog cannot: a working red confirm button.
With a field
Put autoFocus on the field so focus lands there when the modal opens. Without it, MUI focuses the panel itself.
<Modal isOpen={open} onClose={close} title="Rename dashboard" className="w-[500px]" headerClassName="py-3 px-4" titleClassName="text-[14px] font-medium">
<div className="px-4 pb-4">
<TextInput label="Dashboard name" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && save()} autoFocus />
</div>
<div className="flex justify-between items-center py-3 px-4 border-t border-[var(--color-grey-100)]">
<Button variant="ghost" size="lg" label="Cancel" onClick={close} />
<Button variant="primary" size="lg" label="Rename" disabled={!draft.trim() || draft.trim() === name} onClick={save} />
</div>
</Modal>Variants
variant sets the 3px rail colour. Unlike Dialog, it does not disable anything.
<Modal isOpen={open} onClose={close} title="Connect HubSpot" variant="primary">…</Modal>
<Modal isOpen={open} onClose={close} title="Unsaved changes" variant="warning">…</Modal>
<Modal isOpen={open} onClose={close} title="Disconnect Google Ads" variant="error">…</Modal>| Variant | Rail |
|---|---|
primary | --color-primary-500 |
warning | --color-orange |
error | --color-red |
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
isOpen | boolean | Required. | |
onClose | (event, reason) => void | Called by the close button, Escape and a backdrop click. Pass a function that does nothing to make the modal impossible to dismiss, as the terms of service does. | |
title | ReactNode | An <h2>, cut short at 80% of the header width with a tooltip showing the rest. | |
variant | "primary" | "warning" | "error" | "primary" | Rail colour. |
children | ReactNode | The body and any footer. | |
showHeader | boolean | true | false removes the title and the close button. |
showCloseBtn | boolean | true | |
header | ReactNode | Extra content placed in the header before the title. | |
className | string | On the panel. Merged with tailwind-merge, so w-[500px] replaces the default w-96. | |
headerClassName | string | On the header row. Default padding is px-8 pt-6 pb-4. | |
titleClassName | string | On the <h2>. Default is 16px semibold. | |
closeClassName | string | On the close Button. | |
childClassName | string | On the <div> around children. | |
topBorderClassname | string | "" | Merged after the rail classes, so it can override them. The session modals use it for a 5px rail. |
containerClassname | string | "" | On the fixed wrapper that centres the panel. |
center | boolean | true | false pushes the panel to margin-left: 36%. Nothing uses it. |
styles | { modal?: object } | {} | Inline styles for the panel. |
classes | object | {} | Passed to MUI Modal. The backdrop is always #44444466. |
topStripClassName | string | Accepted but not used. Most callers pass "hidden", which does nothing. |
Accessibility
- MUI renders the modal in a portal at the end of
<body>and hides the rest of the page from screen readers while it is open. - Focus moves into the modal on open, to the panel itself or to a field with
autoFocus. Tab and Shift+Tab stay inside it. - Escape and a click on the backdrop call
onClose. Focus returns to the element that opened it. - The page behind does not scroll while it is open.
- The panel has no
role="dialog"and is not linked to its title, so screen readers do not announce it as a dialog. MUI leaves that to the content. If you must add a Modal, wrap the children in an element withrole="dialog"andaria-labelledbypointing at anidyou put on the title. - The close button is a product Button with
aria-label="Close".
Do and don't
- Do use Dialog for new confirmations and forms.
- Do keep existing Modal screens working as they are until they are moved.
- Don't copy a Modal footer into new code. Dialog's footer already does it.
- Don't rely on
topStripClassNameorcenter. The first does nothing and the second is unused.