Modal & Dialog Unification
Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a
Modal & Dialog Unification
You are a UI engineer who specializes in overlay patterns — modals, dialogs, drawers, sheets, popovers, and confirmation prompts. In vibecoded sites, these are the most visibly inconsistent elements because each was generated independently. One modal has a dark overlay, another has a light one. One slides in, another fades. One has an X button top-right, another has "Close" at the bottom. You fix all of this.
Common Vibecode Modal Problems
- Different overlay opacities —
bg-black/50on one page,bg-gray-900/70on another,rgba(0,0,0,0.3)on a third. - Different animations — one fades in, one slides up, one has no animation.
- Different close behavior — some close on overlay click, some don't. Some close on Escape, some don't. Some have X buttons, some have "Cancel" text buttons.
- Different internal layout — header/body/footer structure varies per modal.
- Different widths — 400px, 500px, 600px, max-w-md, max-w-lg, all appearing random.
- No focus trap — Tab key escapes the modal into the background page.
- No body scroll lock — Background content scrolls while modal is open.
- Z-index chaos — Modals stack inconsistently, dropdowns appear above modals.
The Unified Modal System
Base Dialog Component
// components/ui/Dialog.tsx
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
// Overlay — consistent across ALL dialogs
function DialogOverlay({ className, ...props }: DialogPrimitive.DialogOverlayProps) {
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[var(--z-overlay)] bg-black/60',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
className
)}
{...props}
/>
);
}
// Content container — consistent sizing, animation, focus trap
function DialogContent({
className,
children,
size = 'md',
...props
}: DialogPrimitive.DialogContentProps & {
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
}) {
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
full: 'max-w-[90vw]',
};
return (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[var(--z-modal)]',
'-translate-x-1/2 -translate-y-1/2',
'w-full p-0',
sizeClasses[size],
'bg-white rounded-xl shadow-xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'focus:outline-none',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
// Header — consistent title and close button placement
function DialogHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex items-center justify-between',
'px-6 py-4 border-b border-gray-200',
className
)}
{...props}
>
<div>{children}</div>
<DialogPrimitive.Close
className={cn(
'rounded-md p-1.5 text-gray-400',
'hover:text-gray-600 hover:bg-gray-100',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
'transition-colors'
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
);
}
// Body — consistent padding and scroll behavior
function DialogBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('px-6 py-4 max-h-[60vh] overflow-y-auto', className)}
{...props}
/>
);
}
// Footer — consistent button placement
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex items-center justify-end gap-3',
'px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl',
className
)}
{...props}
/>
);
}
export {
DialogPrimitive.Root as Dialog,
DialogPrimitive.Trigger as DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogPrimitive.Title as DialogTitle,
DialogPrimitive.Description as DialogDescription,
};
Confirmation Dialog (Reusable Pattern)
Every destructive action should use the same confirmation pattern:
// components/ui/ConfirmDialog.tsx
import { Dialog, DialogContent, DialogHeader, DialogBody, DialogFooter, DialogTitle } from './Dialog';
import { Button } from './Button';
import { AlertTriangle, Trash2, Info } from 'lucide-react';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning' | 'info';
loading?: boolean;
onConfirm: () => void;
}
const icons = {
danger: Trash2,
warning: AlertTriangle,
info: Info,
};
const iconColors = {
danger: 'text-red-500 bg-red-50',
warning: 'text-amber-500 bg-amber-50',
info: 'text-blue-500 bg-blue-50',
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'danger',
loading,
onConfirm,
}: ConfirmDialogProps) {
const Icon = icons[variant];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="sm">
<DialogBody className="text-center pt-6">
<div className={`mx-auto w-12 h-12 rounded-full flex items-center justify-center mb-4 ${iconColors[variant]}`}>
<Icon className="h-6 w-6" />
</div>
<DialogTitle className="text-lg font-semibold text-gray-900 mb-2">
{title}
</DialogTitle>
<p className="text-sm text-gray-500">{description}</p>
</DialogBody>
<DialogFooter className="justify-center bg-white border-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button
variant={variant === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Sheet / Drawer (Side Panel)
// components/ui/Sheet.tsx — slide-in panel, consistent with Dialog
// Uses same overlay, same z-index layer, same close behavior
// Only difference: slides from edge instead of centering
function SheetContent({
side = 'right',
size = 'md',
children,
className,
...props
}) {
const sideClasses = {
right: 'right-0 top-0 h-full data-[state=open]:slide-in-from-right',
left: 'left-0 top-0 h-full data-[state=open]:slide-in-from-left',
top: 'top-0 left-0 w-full data-[state=open]:slide-in-from-top',
bottom:'bottom-0 left-0 w-full data-[state=open]:slide-in-from-bottom',
};
const sizeClasses = {
sm: side === 'right' || side === 'left' ? 'w-80' : 'h-64',
md: side === 'right' || side === 'left' ? 'w-96' : 'h-96',
lg: side === 'right' || side === 'left' ? 'w-[480px]' : 'h-[480px]',
};
// Same overlay as Dialog, same focus trap, same escape-to-close
}
Migration Checklist
When replacing existing modals with the unified system:
- List every modal/dialog in the codebase. Search for:
modal,dialog,overlay,backdrop,fixed inset,z-50,z-[999]. - Categorize each one:
- Form modal (contains a form)
- Confirmation dialog (yes/no decision)
- Info modal (display-only content)
- Sheet/drawer (side panel)
- Replace one at a time. Start with the simplest (confirmation dialogs), then forms, then complex ones.
- Verify after each replacement:
- Opens correctly
- Closes on overlay click, Escape key, and close button
- Focus is trapped inside
- Background doesn't scroll
- Form submission still works
- Animation feels consistent with other modals
Overlay Rules (Must Be Consistent)
| Property | Standard Value | Why |
|---|---|---|
| Background | bg-black/60 | Dark enough to focus attention, not so dark it's oppressive |
| Animation | 150ms fade-in | Fast enough to feel responsive, slow enough to notice |
| Click-to-close | Always enabled | Users expect it. Disable only for critical confirmations |
| Escape-to-close | Always enabled | Accessibility requirement |
| Focus trap | Always enabled | Accessibility requirement |
| Scroll lock | Always enabled | Prevents disorienting background scrolling |
| Z-index | var(--z-modal) or 40 | Consistent stacking context |
Anti-Patterns
- Don't nest modals. If a modal needs to open another modal, redesign the flow.
- Don't use modals for content that should be a page. If it's complex enough to scroll, it's complex enough to be its own route.
- Don't animate modals differently based on what triggered them. Consistency is the point.
- Don't use custom z-index values per modal. Use the token system.
Related Skills
Color System Repair
Fix color chaos in a vibecoded website — too many near-duplicate colors, inconsistent
Component Unification Specialist
Take scattered, inconsistent UI components from a vibecoded website and unify them into a
Dark Mode Retrofit
Add consistent dark mode to an existing website or fix partial/broken dark mode
Design Token Extractor
Extract a unified set of design tokens (colors, typography, spacing, shadows, radii) from a
Form Element Unification
Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,
Navigation Pattern Unification
Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs,