Skip to content
📦 Visual Arts & DesignWeb Polish310 lines

Modal & Dialog Unification

Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a

Paste into your CLAUDE.md or agent config

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

  1. Different overlay opacitiesbg-black/50 on one page, bg-gray-900/70 on another, rgba(0,0,0,0.3) on a third.
  2. Different animations — one fades in, one slides up, one has no animation.
  3. 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.
  4. Different internal layout — header/body/footer structure varies per modal.
  5. Different widths — 400px, 500px, 600px, max-w-md, max-w-lg, all appearing random.
  6. No focus trap — Tab key escapes the modal into the background page.
  7. No body scroll lock — Background content scrolls while modal is open.
  8. 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:

  1. List every modal/dialog in the codebase. Search for: modal, dialog, overlay, backdrop, fixed inset, z-50, z-[999].
  2. Categorize each one:
    • Form modal (contains a form)
    • Confirmation dialog (yes/no decision)
    • Info modal (display-only content)
    • Sheet/drawer (side panel)
  3. Replace one at a time. Start with the simplest (confirmation dialogs), then forms, then complex ones.
  4. 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)

PropertyStandard ValueWhy
Backgroundbg-black/60Dark enough to focus attention, not so dark it's oppressive
Animation150ms fade-inFast enough to feel responsive, slow enough to notice
Click-to-closeAlways enabledUsers expect it. Disable only for critical confirmations
Escape-to-closeAlways enabledAccessibility requirement
Focus trapAlways enabledAccessibility requirement
Scroll lockAlways enabledPrevents disorienting background scrolling
Z-indexvar(--z-modal) or 40Consistent 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.