The dialog system has two layers:
Dialog, DialogTrigger, DialogContent — render a dialog inline with a trigger button.useDialog + DialogManager): Open any registered dialog from anywhere in your app by calling openDialog('dialogType', props). Supports dialog stacking, dynamic props, and a centralised registry.Destructive confirmations (delete, discard, cancel subscription). Forms that gate a workflow (create audience, campaign settings). Alerts the user must read before continuing.
If a dialog can be opened from multiple places (e.g. a global "Upgrade plan" modal), register it with DialogManager and open via useDialog().openDialog('upgrade', props).
Successful saves, transient errors, brief info → use Toaster. Persistent informational banners → use Alert.
Anything more than ~2 steps belongs on a dedicated page or in a wizard. Modal stacking is supported but should be rare.
Picking from a short list of options → Dropdown or Popover. Filtering options on a page → keep them inline.
| You want… | Use this |
|---|---|
| Brief auto-dismissing feedback ("Saved!", "Failed to send") | Toaster (toaster.success(...)) |
| Persistent informational banner that stays on screen | Alert |
| Destructive action requiring "yes" confirmation | ConfirmationDialog (a thin wrapper over Dialog) |
| Form taking over the screen | Dialog with a form inside |
| Side-anchored panel (filters, details) | Drawer |
| Quick option picker | Popover or Dropdown |
| Tooltip on hover/focus | Tooltip (or HoverCard for richer content) |
npm install @marmoui/uiyarn add @marmoui/uipnpm add @marmoui/uibun add @marmoui/ui// Declarative (inline trigger)
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, Button } from '@marmoui/ui';
// Programmatic (open from anywhere)
import { DialogProvider, DialogManager, useDialog } from '@marmoui/ui';DialogHeaderDialogHeader renders a persistent title bar with a bottom border separator. It is not needed for every dialog — adding it to simple confirmations creates unnecessary visual weight. When you do use DialogHeader, usually keep it to DialogTitle only and put explanatory copy in the dialog body instead of adding DialogDescription to the header.
Use it when the dialog has multiple fields, a sidebar, or contextual navigation. The title bar anchors the user and persists as content changes.
Large dialogs benefit from a visible title bar as spatial orientation. The close button alone isn't sufficient at wide viewports.
Simple "Are you sure?" dialogs don't need a header. Put DialogTitle and DialogDescription directly in the content — the dialog is focused and temporary.
If DialogHeader is present, usually omit DialogDescription. Put helper copy near the fields or in DialogContent body copy.
Delete and discard confirmations follow the same rule. A header adds noise to an already urgent message.
No DialogHeader. DialogTitle and DialogDescription go directly inside DialogContent. Best for quick confirmations, alerts, and single-purpose prompts.
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" size="sm">
Open Dialog
</Button>
</DialogTrigger>
<DialogContent size="sm">
<DialogTitle>Are you sure you want to perform this action?</DialogTitle>
<DialogDescription>
Please confirm your intention to proceed by clicking the "Confirm" button below.
</DialogDescription>
<DialogFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog><Dialog>
<DialogTrigger asChild>
<Button variant="secondary">Show Confirmation</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Are you sure you want to perform this action?</DialogTitle>
<DialogDescription>
Please confirm your intention to proceed by clicking the "Confirm" button below.
</DialogDescription>
<DialogFooter className="sm:justify-between sm:items-center">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<Checkbox />
Don't show me this again
</label>
<div className="flex gap-2">
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Confirm</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog><Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
Delete Item
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Are you sure you want to perform this delete action?</DialogTitle>
<DialogDescription>
Please confirm your intention to proceed by clicking the "Delete" button below.
</DialogDescription>
<DialogFooter className="sm:justify-between sm:items-center">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<Checkbox />
Don't show me this again
</label>
<div className="flex gap-2">
<Button variant="secondary">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>Uses DialogHeader because the form has multiple fields and users benefit from a persistent title. Avoid DialogDescription in the header; place helper copy in the body if needed.
<Dialog>
<DialogTrigger asChild>
<Button variant="primary" size="sm">
Create Audience
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Audience</DialogTitle>
</DialogHeader>
<div className="pt-4">
<div className="mb-2">
<label className="text-sm font-medium">
Audience Name <span className="text-red-500">*</span>
</label>
<Input placeholder="e.g. All Subscribers" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Matching Lists</label>
<Input placeholder="All Lists" />
</div>
</div>
<DialogFooter>
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>Control open/close state with React state. No header — this is a focused single-purpose dialog.
function ControlledExample() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Open Controlled</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Controlled Dialog</DialogTitle>
<DialogDescription>
This dialog state is controlled by React state.
</DialogDescription>
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}DialogContent accepts a size prop: sm (320px), md (620px, default), lg (980px), xl (1120px), or full (covers the entire viewport).
Click each button to see the different dialog widths
// Small — 320px
<DialogContent size="sm">...</DialogContent>
// Medium — 620px (default)
<DialogContent size="md">...</DialogContent>
// Large — 980px
<DialogContent size="lg">...</DialogContent>
// Extra Large — 1120px
<DialogContent size="xl">...</DialogContent>
// Full — covers entire viewport
<DialogContent size="full">...</DialogContent>By default dialogs render at their natural height centered in the viewport. When content is long, choose the scroll pattern that best fits the UX:
| Pattern | When to use |
|---|---|
| Sticky footer | Long forms or settings where the action buttons must always be visible |
| Scrollable content | Browsable lists where no explicit confirmation is needed |
| Outside scroll | Legal text, read-only documents, long articles where the dialog grows naturally |
The scrollable prop on DialogContent applies max-h-[80vh] and grid-rows-[auto_1fr_auto] — the header/footer rows are auto-height and the 1fr middle row gives ScrollArea a concrete computed height so its internal viewport resolves correctly. Give ScrollArea className="min-h-0" so the grid can shrink it within the 1fr row.
Header and footer are pinned — a long input form scrolls in between
<DialogContent scrollable>
<DialogHeader>
<DialogTitle>Create Contact</DialogTitle>
</DialogHeader>
<ScrollArea className="min-h-0">
<div className="py-6 space-y-4 pr-2">
<p className="text-sm text-ink-light">Fill in all details.</p>
<Input placeholder="First Name" />
<Input placeholder="Last Name" />
{/* ...more fields... */}
</div>
</ScrollArea>
{/* mt-0 removes default gap; border-t adds a visual separator */}
<DialogFooter className="mt-0 pt-6 border-t border-border-secondary">
<Button variant="secondary">Cancel</Button>
<Button variant="primary">Create Contact</Button>
</DialogFooter>
</DialogContent>When a dialog contains a browsable list with no explicit confirmation step (selection happens via row click or the dialog is purely informational), omit DialogFooter. The ScrollArea expands to fill all remaining space below the header.
Header stays pinned — the list fills remaining space and scrolls
<DialogContent scrollable>
<DialogHeader>
<DialogTitle>Choose a Template</DialogTitle>
</DialogHeader>
<ScrollArea className="min-h-0">
<div className="py-2 pr-2">
<p className="mb-3 text-sm text-ink-light">Select a starting point.</p>
{templates.map((name, i) => (
<div key={i} className="flex items-center gap-3 py-3 border-b last:border-0 cursor-pointer hover:bg-bg rounded">
...
</div>
))}
</div>
</ScrollArea>
</DialogContent>Add the outsideScroll prop to DialogContent. The dialog renders without a max-height and grows naturally with its content. The dark backdrop becomes a scrollable container so users scroll the overlay rather than an internal area. Best for long read-only content like terms, changelogs, or documentation.
The dialog grows to fit its content — users scroll the backdrop overlay
// The dialog has no max-height; it expands naturally.
// The backdrop overlay becomes scrollable.
<DialogContent outsideScroll>
<DialogHeader>
<DialogTitle>Terms & Conditions</DialogTitle>
</DialogHeader>
<div className="pt-6 space-y-5">
<p className="text-sm text-ink-light">Scroll down to read all sections.</p>
{sections.map((s) => (
<div key={s.title}>
<h4 className="text-sm font-medium text-ink-dark mb-1">{s.title}</h4>
<p className="text-sm text-ink-light">{s.body}</p>
</div>
))}
</div>
<DialogFooter>
<Button variant="secondary">Decline</Button>
<Button variant="primary">Accept & Continue</Button>
</DialogFooter>
</DialogContent>For apps with many dialogs, the programmatic approach is cleaner. Instead of embedding Dialog + DialogTrigger everywhere, you register dialog components in a central config and open them from anywhere with openDialog().
DialogProvider ← Wraps your app, manages dialog stack
├── Your App
│ └── useDialog() ← Call openDialog('type', props) from any component
└── DialogManager ← Renders the active dialog from the stack
└── config ← Registry mapping type strings to componentsDialog components receive onClose and any props you pass to openDialog. They render the inner content only — DialogManager handles the outer Dialog and DialogContent wrapper.
Each dialog is a standalone component that receives onClose and custom props
export function DeleteCampaignDialog({
dialogTitle,
campaignName,
onClose,
}: {
dialogTitle: string;
campaignName: string;
onClose: () => void;
}) {
return (
<>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
{'Are you sure you want to delete ' + campaignName + '?'}
</DialogDescription>
<DialogFooter>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={onClose}>
Delete Campaign
</Button>
</DialogFooter>
</>
);
}Map string keys to dialog components. Use dynamic imports for code splitting.
// components/dialogs/index.ts
import dynamic from 'next/dynamic';
import type { DialogManagerConfig } from '@marmoui/ui';
export const appDialogConfig: DialogManagerConfig = {
dialogs: {
// Campaign dialogs
deleteCampaign: dynamic(() => import('./delete-campaign-dialog'), { ssr: false }),
createCampaign: dynamic(() => import('./create-campaign-dialog'), { ssr: false }),
// Contact dialogs
createContact: dynamic(() => import('./create-contact-dialog'), { ssr: false }),
deleteContact: dynamic(() => import('./delete-contact-dialog'), { ssr: false }),
sendEmail: dynamic(() => import('./send-email-dialog'), { ssr: false }),
editContact: dynamic(() => import('./edit-contact-dialog'), { ssr: false }),
},
defaultConfig: {
size: 'md',
closeOnEscape: true,
closeOnInteractOutside: true,
},
};Wrap your app with DialogProvider and place DialogManager at the root.
// app/providers.tsx
import { DialogProvider, DialogManager } from '@marmoui/ui';
import { appDialogConfig } from '@/components/dialogs';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<DialogProvider>
{children}
<DialogManager config={appDialogConfig} />
</DialogProvider>
);
}Call useDialog() in any component to get openDialog. Pass the dialog type string and any props the dialog component expects.
Open any registered dialog from any component with openDialog()
Click any button to open a dialog via useDialog():
import { useDialog, Button } from '@marmoui/ui';
function CampaignActions({ campaign }) {
const { openDialog } = useDialog();
return (
<div className="flex gap-2">
<Button
variant="destructive"
onClick={() => openDialog('deleteCampaign', {
dialogTitle: `Delete "${campaign.name}"?`,
dialogDescription: 'This action cannot be undone.',
campaignName: campaign.name,
})}
>
Delete
</Button>
<Button
variant="primary"
onClick={() => openDialog('createContact')}
>
Create Contact
</Button>
<Button
variant="secondary"
onClick={() => openDialog('sendEmail', {
contactName: 'Jane Smith',
})}
>
Send Email
</Button>
</div>
);
}DialogStack renders multiple dialogs as a visual stack — previous dialogs appear as cards pushed behind the active one. Use DialogStackNext and DialogStackPrevious to navigate between them.
Navigate through multiple dialogs with a visual stack effect
<DialogStack>
<DialogStackTrigger asChild>
<Button>Open Stacked Dialogs</Button>
</DialogStackTrigger>
<DialogStackOverlay />
<DialogStackBody>
<DialogStackContent>
<DialogStackHeader>
<DialogStackTitle>Campaign Details</DialogStackTitle>
<DialogStackDescription>Fill in the basics.</DialogStackDescription>
</DialogStackHeader>
<div className="py-4">
<Input placeholder="Campaign name..." />
</div>
<DialogStackFooter>
<DialogStackPrevious asChild>
<Button variant="secondary" size="sm">Previous</Button>
</DialogStackPrevious>
<DialogStackNext asChild>
<Button variant="primary" size="sm">Next</Button>
</DialogStackNext>
</DialogStackFooter>
</DialogStackContent>
<DialogStackContent>
<DialogStackHeader>
<DialogStackTitle>Select Audience</DialogStackTitle>
<DialogStackDescription>Choose recipients.</DialogStackDescription>
</DialogStackHeader>
<div className="py-4">
<Input placeholder="All subscribers" />
</div>
<DialogStackFooter>
<DialogStackPrevious asChild>
<Button variant="secondary" size="sm">Previous</Button>
</DialogStackPrevious>
<DialogStackNext asChild>
<Button variant="primary" size="sm">Next</Button>
</DialogStackNext>
</DialogStackFooter>
</DialogStackContent>
<DialogStackContent>
<DialogStackHeader>
<DialogStackTitle>Review and Send</DialogStackTitle>
<DialogStackDescription>Confirm and launch.</DialogStackDescription>
</DialogStackHeader>
<div className="py-4">
<p className="text-sm text-ink-light">Ready to send.</p>
</div>
<DialogStackFooter>
<DialogStackPrevious asChild>
<Button variant="secondary" size="sm">Previous</Button>
</DialogStackPrevious>
<Button variant="primary" size="sm">Send Campaign</Button>
</DialogStackFooter>
</DialogStackContent>
</DialogStackBody>
</DialogStack>Set clickable on DialogStack to allow users to click previous (stacked) cards to navigate back.
Click previous dialog cards to navigate back
<DialogStack clickable>
<DialogStackTrigger asChild>
<Button>Open Clickable Stack</Button>
</DialogStackTrigger>
<DialogStackOverlay />
<DialogStackBody>
<DialogStackContent>...</DialogStackContent>
<DialogStackContent>...</DialogStackContent>
<DialogStackContent>...</DialogStackContent>
</DialogStackBody>
</DialogStack>Pass a third argument to openDialog to override the default config per dialog:
openDialog('manageFolders', { type: 'campaign' }, {
size: 'lg', // Override size for this dialog
closeOnInteractOutside: false, // Prevent closing by clicking outside
closeOnEscape: false, // Prevent closing with Escape
role: 'alertdialog', // ARIA role for critical dialogs
});Every dialog automatically receives dialogTitle and dialogDescription from the props you pass to openDialog. This lets you customize the title per invocation:
// From a contacts table row action
openDialog('deleteContact', {
dialogTitle: `Delete "${contact.email}"?`,
dialogDescription: 'This action cannot be undone.',
contactEmail: contact.email,
contactId: contact.id,
});
// From a bulk action bar
openDialog('deleteContact', {
dialogTitle: `Delete ${selectedContacts.length} contacts?`,
dialogDescription: 'This will permanently remove all selected contacts.',
contacts: selectedContacts,
});Here is a complete setup from the Marmo Campaign app showing how all the pieces fit together:
// 1. Root provider (app/providers.tsx)
import { DialogProvider, DialogManager } from '@marmoui/ui';
import { appDialogConfig } from '@/components/dialogs';
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>
<DialogProvider>
<SidebarProvider mainNavItems={navItems}>
{children}
<DialogManager config={appDialogConfig} />
</SidebarProvider>
</DialogProvider>
</QueryClientProvider>
);
}
// 2. Dialog registry (components/dialogs/index.ts)
import dynamic from 'next/dynamic';
import type { DialogManagerConfig } from '@marmoui/ui';
export const appDialogConfig: DialogManagerConfig = {
dialogs: {
deleteCampaign: dynamic(() => import('../campaigns/delete-campaign-dialog'), { ssr: false }),
createCampaign: dynamic(() => import('../campaigns/create-campaign-dialog'), { ssr: false }),
createContact: dynamic(() => import('../contacts/create-contact-dialog'), { ssr: false }),
deleteContact: dynamic(() => import('../contacts/delete-contact-dialog'), { ssr: false }),
sendEmail: dynamic(() => import('../contacts/send-email-dialog'), { ssr: false }),
editContact: dynamic(() => import('../contacts/edit-contact-dialog'), { ssr: false }),
addToList: dynamic(() => import('../contacts/add-to-list-dialog'), { ssr: false }),
viewProfile: dynamic(() => import('../contacts/view-profile-dialog'), { ssr: false }),
templatePreview: dynamic(() => import('../campaigns/template-preview-dialog')
.then(mod => ({ default: mod.TemplatePreviewDialog })), { ssr: false }),
},
defaultConfig: {
size: 'md',
closeOnEscape: true,
closeOnInteractOutside: true,
},
};
// 3. Using in a data table row (components/contacts/contacts-list.tsx)
function ContactRowActions({ contact }) {
const { openDialog } = useDialog();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton variant="ghost" size="sm"><MdMoreVert /></IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => openDialog('viewProfile', { contact })}>
View Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog('editContact', { contact })}>
Edit Contact
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog('sendEmail', { contact })}>
Send Email
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog('addToList', { contact })}>
Add to List
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => openDialog('deleteContact', {
dialogTitle: `Delete "${contact.email}"?`,
contactEmail: contact.email,
contactId: contact.id,
})}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}For simple confirmations and alerts, put DialogTitle and DialogDescription directly inside DialogContent — no DialogHeader needed.
Use DialogHeader when content is richer — forms, settings panels, or any dialog where a persistent title bar helps orientation.
Register all dialogs in a central config. This keeps dialog logic centralized and enables code splitting via dynamic imports.
Use specific labels like "Delete Campaign", "Save Changes", "Send Email" instead of generic "Yes" or "OK".
Only wrap your app root with one DialogProvider. All components share the same dialog stack.
Registered dialog components should render only the inner content (Header, body, Footer). DialogManager handles the outer Dialog and DialogContent wrapper.
Dialogs interrupt the user. For informational messages, use Toast. For quick selections, use Popover or Dropdown.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state |
onOpenChange | (open: boolean) => void | — | Called when open state changes |
defaultOpen | boolean | false | Uncontrolled initial open state |
modal | boolean | true | Whether to render as modal |
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Dialog width |
showCloseButton | boolean | true | Show the X close button |
className | string | — | Additional className |
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Dialog width |
closeOnEscape | boolean | true | Allow closing with Escape key |
closeOnInteractOutside | boolean | true | Allow closing by clicking overlay |
role | 'dialog' | 'alertdialog' | 'dialog' | ARIA role |
const {
openDialog, // (type: string, props?: object, config?: DialogConfig) => void
closeDialog, // () => void — closes the top dialog
closeAllDialogs, // () => void — clears the entire stack
dialogStack, // DialogEntry[] — current stack of open dialogs
} = useDialog();interface DialogManagerConfig {
dialogs: Record<string, ComponentType<any>>; // Dialog registry
defaultConfig?: {
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
closeOnEscape?: boolean;
closeOnInteractOutside?: boolean;
};
}Focus Management
Dialogs automatically trap focus and return it to the trigger element when closed. Ensure all interactive elements inside dialogs are keyboard accessible.
closeOnEscape: false)DialogTitle for the dialog heading (renders as h2)DialogDescription for screen reader contextrole="alertdialog" in config for critical confirmations