Design system

Modal

The older MUI-based modal still used by settings, sessions and dashboards. Use Dialog for new work.

import { Modal } from "@/ui";

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.

ModalDialog
Built onMUI ModalPlain markup and the design-system tokens
Backdrop, portal, scroll lockYesNo. The caller adds a backdrop.
Escape and click outside close itYesNo. The caller adds them.
Focus moved in, kept in, and returnedYesNo
role="dialog" and a nameNoNo. The caller adds them.
SizesOne, 384px. Every caller overrides it, usually to 500px.460, 650 and 800px
FooterNone. Every caller builds Cancel and Confirm by hand.Built in
Header16px 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
Rail3px4px
Used in10 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>
VariantRail
primary--color-primary-500
warning--color-orange
error--color-red

Props

PropTypeDefaultNotes
isOpenbooleanRequired.
onClose(event, reason) => voidCalled 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.
titleReactNodeAn <h2>, cut short at 80% of the header width with a tooltip showing the rest.
variant"primary" | "warning" | "error""primary"Rail colour.
childrenReactNodeThe body and any footer.
showHeaderbooleantruefalse removes the title and the close button.
showCloseBtnbooleantrue
headerReactNodeExtra content placed in the header before the title.
classNamestringOn the panel. Merged with tailwind-merge, so w-[500px] replaces the default w-96.
headerClassNamestringOn the header row. Default padding is px-8 pt-6 pb-4.
titleClassNamestringOn the <h2>. Default is 16px semibold.
closeClassNamestringOn the close Button.
childClassNamestringOn the <div> around children.
topBorderClassnamestring""Merged after the rail classes, so it can override them. The session modals use it for a 5px rail.
containerClassnamestring""On the fixed wrapper that centres the panel.
centerbooleantruefalse pushes the panel to margin-left: 36%. Nothing uses it.
styles{ modal?: object }{}Inline styles for the panel.
classesobject{}Passed to MUI Modal. The backdrop is always #44444466.
topStripClassNamestringAccepted 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 with role="dialog" and aria-labelledby pointing at an id you 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 topStripClassName or center. The first does nothing and the second is unused.

On this page