Thrive Design System

Components

Dialog

A modal dialog system with programmatic control, dialog stacking, and a centralized registry via useDialog and DialogManager.

Installation

npm install @thrivecart/ui
yarn add @thrivecart/ui
pnpm add @thrivecart/ui
bun add @thrivecart/ui

Usage

// Declarative (inline trigger)
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, Button } from '@thrivecart/ui';

// Programmatic (open from anywhere)
import { DialogProvider, DialogManager, useDialog } from '@thrivecart/ui';

Declarative Dialogs

When to Use DialogHeader

DialogHeader 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.

Use DialogHeader for forms and panels

Use it when the dialog has multiple fields, a sidebar, or contextual navigation. The title bar anchors the user and persists as content changes.

Use DialogHeader for lg and xl dialogs

Large dialogs benefit from a visible title bar as spatial orientation. The close button alone isn't sufficient at wide viewports.

Don't use DialogHeader for confirmations

Simple "Are you sure?" dialogs don't need a header. Put DialogTitle and DialogDescription directly in the content — the dialog is focused and temporary.

Don't use DialogHeader for destructive actions

Delete and discard confirmations follow the same rule. A header adds noise to an already urgent message.


Simple Confirmation

No DialogHeader. DialogTitle and DialogDescription go directly inside DialogContent. Best for quick confirmations, alerts, and single-purpose prompts.

Simple Confirmation

<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>

Confirmation with Checkbox

<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&apos;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>

Destructive Confirmation

<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&apos;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>

Create / Edit Form

Uses DialogHeader because the form has multiple fields and users benefit from a persistent label.

Create / Edit Form

<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>

Controlled Dialog

Control open/close state with React state. No header — this is a focused single-purpose dialog.

Controlled Dialog

function ControlledExample() {
const [open, setOpen] = useState(false);

return (
  <Dialog open={open} onOpenChange={setOpen}>
    <DialogTrigger asChild>
      <Button>Open Controlled</Button>
    </DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Controlled Dialog</DialogTitle>
        <DialogDescription>
          This dialog state is controlled by React state.
        </DialogDescription>
      </DialogHeader>
      <DialogFooter>
        <Button variant="secondary" onClick={() => setOpen(false)}>
          Close
        </Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
);
}

Dialog Sizes

DialogContent accepts a size prop: sm (320px), md (620px, default), lg (980px), xl (1120px), or full (covers the entire viewport).

  • sm — quick confirmations, short prompts
  • md — most dialogs and forms (default)
  • lg — settings panels, complex forms, sidebars
  • xl — rich content, data tables, wide layouts
  • full — immersive flows, editors, mobile-first experiences

Size Variants

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>

Scroll Patterns

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:

PatternWhen to use
Sticky footerLong forms or settings where the action buttons must always be visible
Scrollable contentBrowsable lists where no explicit confirmation is needed
Outside scrollLegal 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.

Sticky Footer (Form)

Header and footer are pinned — a long input form scrolls in between

<DialogContent scrollable>
<DialogHeader>
  <DialogTitle>Create Contact</DialogTitle>
  <DialogDescription>Fill in all details.</DialogDescription>
</DialogHeader>

<ScrollArea className="min-h-0">
  <div className="py-6 space-y-4 pr-2">
    <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.

Scrollable Content

Header stays pinned — the list fills remaining space and scrolls

<DialogContent scrollable>
<DialogHeader>
  <DialogTitle>Choose a Template</DialogTitle>
  <DialogDescription>Select a starting point.</DialogDescription>
</DialogHeader>

<ScrollArea className="min-h-0">
  <div className="py-2 pr-2">
    {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>

Outside Scroll

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.

Outside Scroll

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 &amp; Conditions</DialogTitle>
  <DialogDescription>Scroll down to read all sections.</DialogDescription>
</DialogHeader>

<div className="pt-6 space-y-5">
  {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 &amp; Continue</Button>
</DialogFooter>
</DialogContent>

Programmatic Dialogs with useDialog

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().

Architecture

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 components

Step 1: Create Dialog Components

Dialog components receive onClose and any props you pass to openDialog. They render the inner content only — DialogManager handles the outer Dialog and DialogContent wrapper.

Dialog Component Pattern

Each dialog is a standalone component that receives onClose and custom props


  export function DialogComponentPatternPreview({
    dialogTitle,
    dialogDescription,
    campaignName,
    onClose,
  }: {
    dialogTitle: string;
    dialogDescription: string;
    campaignName: string;
    onClose: () => void;
  }) {
    return (
      <Dialog>
        <DialogTrigger asChild>
          <Button variant="destructive" size="sm">
            Delete Campaign
          </Button>
        </DialogTrigger>
        <DialogContent>
          <DialogTitle>{dialogTitle}</DialogTitle>
          <DialogDescription>
            `Are you sure you want to delete this ${campaignName}?`}
          </DialogDescription>
          <DialogFooter>
            <Button variant="secondary" onClick={onClose}>
              Cancel
            </Button>
            <Button variant="destructive" onClick={onClose}>
              Delete Campaign
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    );
  }

Step 2: Create the Dialog Registry

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 '@thrivecart/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,
  },
};

Step 3: Wire Up the Provider

Wrap your app with DialogProvider and place DialogManager at the root.

// app/providers.tsx
import { DialogProvider, DialogManager } from '@thrivecart/ui';
import { appDialogConfig } from '@/components/dialogs';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <DialogProvider>
      {children}
      <DialogManager config={appDialogConfig} />
    </DialogProvider>
  );
}

Step 4: Open Dialogs from Anywhere

Call useDialog() in any component to get openDialog. Pass the dialog type string and any props the dialog component expects.

useDialog Hook

Open any registered dialog from any component with openDialog()

Click any button to open a dialog via useDialog():

import { useDialog, Button } from '@thrivecart/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>
);
}

Dialog Stack

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.

Stacked Dialogs

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>

Clickable Navigation

Set clickable on DialogStack to allow users to click previous (stacked) cards to navigate back.

Clickable Stack

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>

Dialog Config Options

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
});

Dynamic Title and Description

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,
});

Real-World Example

Here is a complete setup from the Thrive Campaign app showing how all the pieces fit together:

// 1. Root provider (app/providers.tsx)
import { DialogProvider, DialogManager } from '@thrivecart/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 '@thrivecart/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>
  );
}

Best Practices

Use no-header layout for confirmations

For simple confirmations and alerts, put DialogTitle and DialogDescription directly inside DialogContent — no DialogHeader needed.

Use DialogHeader for forms and settings

Use DialogHeader when content is richer — forms, settings panels, or any dialog where a persistent title bar helps orientation.

Use the registry for app-wide dialogs

Register all dialogs in a central config. This keeps dialog logic centralized and enables code splitting via dynamic imports.

Use action-oriented button labels

Use specific labels like "Delete Campaign", "Save Changes", "Send Email" instead of generic "Yes" or "OK".

Don't nest DialogProvider

Only wrap your app root with one DialogProvider. All components share the same dialog stack.

Don't render Dialog wrapper in registered components

Registered dialog components should render only the inner content (Header, body, Footer). DialogManager handles the outer Dialog and DialogContent wrapper.

Don't use for non-critical content

Dialogs interrupt the user. For informational messages, use Toast. For quick selections, use Popover or Dropdown.

Props

Dialog

PropTypeDefaultDescription
openbooleanControlled open state
onOpenChange(open: boolean) => voidCalled when open state changes
defaultOpenbooleanfalseUncontrolled initial open state
modalbooleantrueWhether to render as modal

DialogContent

PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Dialog width
showCloseButtonbooleantrueShow the X close button
classNamestringAdditional className

DialogConfig (for openDialog)

PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Dialog width
closeOnEscapebooleantrueAllow closing with Escape key
closeOnInteractOutsidebooleantrueAllow closing by clicking overlay
role'dialog' | 'alertdialog''dialog'ARIA role

useDialog

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();

DialogManagerConfig

interface DialogManagerConfig {
  dialogs: Record<string, ComponentType<any>>;  // Dialog registry
  defaultConfig?: {
    size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
    closeOnEscape?: boolean;
    closeOnInteractOutside?: boolean;
  };
}

Accessibility

Focus Management

Dialogs automatically trap focus and return it to the trigger element when closed. Ensure all interactive elements inside dialogs are keyboard accessible.

  • Focus is automatically trapped within the dialog
  • Pressing Escape closes the dialog (unless closeOnEscape: false)
  • Focus returns to the trigger element on close
  • Use DialogTitle for the dialog heading (renders as h2)
  • Provide DialogDescription for screen reader context
  • Use role="alertdialog" in config for critical confirmations
  • All buttons and inputs are keyboard navigable