Modal & Dialog Unification

Visual Arts & Design Advanced web-polish-skills universal
0 Upvotes
7 Views
2 Downloads
1,072 Words

Description

Unify all modals, dialogs, drawers, sheets, and overlays into a single, consistent system with shared styling, animation, and accessibility.

When to Use

When auditing a site for modal consistency | If modals, drawers, or overlays vary in animation or overlay color | Before launching a unified design system for dialogs | If focus trapping or scroll lock is missing in modals

Use Cases

Audit all modals for consistency across pages | Create a base Dialog component with shared API | Enforce consistent overlay styles and z-index | Ensure focus trap and scroll lock behavior

SKILL.md Content

---
name: modal-dialog-system
description: "Unify all modals, dialogs, drawers, sheets, and overlays into a single, consistent system with shared styling, animation, and accessibility."
metadata:
  tags: "visual-design, ui-components, modal-dialog, overlay-patterns, design-system, consistency, accessibility"
  source: "https://skilldb.dev/skills/web-polish-skills/modal-dialog-system"
  pack: "web-polish-skills"
  category: "Visual Arts & Design"
---

# Modal & Dialog Unification

## When to use this skill
Use when the user says things like:
- "When auditing a site for modal consistency"
- "If modals, drawers, or overlays vary in animation or overlay color"
- "Before launching a unified design system for dialogs"
- "If focus trapping or scroll lock is missing in modals"


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 opacities** — `bg-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

```tsx
// 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:

```tsx
// 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)

```tsx
// 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)

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