Skip to main content
Visual Arts & DesignWeb Polish318 lines

Modal Dialog System

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

Quick Summary18 lines
Modals and dialogs are the most visibly inconsistent elements in vibecoded sites because each one was generated independently and they are the first thing users compare side by side. When the settings modal has a different overlay opacity, animation, and close button position from the delete confirmation dialog, the inconsistency breaks the illusion that the site was intentionally designed. One unified modal system with consistent overlay, animation, focus behavior, and internal layout fixes this across every dialog on the site.

## Key Points

1. **Different overlay opacities** — `bg-black/50` on one page, `bg-gray-900/70` on another,
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,
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.
1. **List every modal/dialog in the codebase.** Search for: `modal`, `dialog`, `overlay`,
2. **Categorize each one:**
- Form modal (contains a form)
- Confirmation dialog (yes/no decision)
skilldb get web-polish-skills/Modal Dialog SystemFull skill: 318 lines
Paste into your CLAUDE.md or agent config

Modal & Dialog Unification

Core Philosophy

Modals and dialogs are the most visibly inconsistent elements in vibecoded sites because each one was generated independently and they are the first thing users compare side by side. When the settings modal has a different overlay opacity, animation, and close button position from the delete confirmation dialog, the inconsistency breaks the illusion that the site was intentionally designed. One unified modal system with consistent overlay, animation, focus behavior, and internal layout fixes this across every dialog on the site.

Overlay patterns are accessibility requirements disguised as design decisions. Focus trapping (preventing Tab from escaping the modal into the background), scroll locking (preventing the page behind the modal from scrolling), keyboard dismissal (Escape key closing the modal), and screen reader announcements are not polish items -- they are functional requirements. Every modal implementation must include all four, which is why building on an accessible primitive like Radix Dialog or Headless UI is strongly preferred over custom implementations.

The most important consistency rule for modals is that every overlay interaction behaves identically regardless of content. Whether the dialog contains a simple confirmation, a complex form, or a long scrollable list, the overlay opacity, animation timing, close behavior, and z-index stacking must be the same. Users build mental models of how overlays work, and breaking those models creates confusion.

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.

Install this skill directly: skilldb add web-polish-skills

Get CLI access →