Skip to main content
Visual Arts & DesignWeb Polish317 lines

Modal Dialog System

Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a single consistent system. Trigger when modals look different across pages, close buttons don't match, overlays have different opacity, or dialog behavior is inconsistent.

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: 317 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 →

Related Skills

Navigation Patterns

Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs, tab bars, and mobile menus. Establish unified navigation components that work across all pages. Trigger when nav "looks different on different pages," active states don't match, or mobile menu is broken/missing.

Web Polish318L

Responsive Repair

Fix broken responsive layouts in a vibecoded website — content overflowing on mobile, misaligned grids, missing breakpoint handling, touch targets that are too small, and navigation that doesn't adapt. Trigger when things "break on mobile," "look bad on tablet," or "don't resize properly."

Web Polish301L

Typography Cleanup

Fix font chaos in a vibecoded website — too many font sizes, inconsistent weights, broken heading hierarchy, mismatched line heights, and mixed font families. Establish a clean type scale and apply it everywhere. Trigger when fonts "look off," headings are inconsistent, or there are too many text sizes.

Web Polish255L

UI Audit

Audit a vibecoded or inconsistent website for visual and structural problems — mismatched components, font chaos, color drift, spacing irregularities, broken states, and missing interactions. Produces a prioritized fix list. Trigger when users say "this looks off," "make it consistent," "audit the UI," "fix the design," or "polish this site."

Web Polish204L

Vibecode Upgrade

The comprehensive playbook for upgrading a vibecoded website from "it works" to "it feels premium." Combines all other web-polish skills into a prioritized workflow — audit, extract tokens, unify components, fix responsiveness, add polish. Trigger when users want to "upgrade the whole site," "make it look professional," "polish everything," or "it was vibecoded and needs to be cleaned up."

Web Polish237L

Visual Polish

Apply the final layer of visual polish to a website — hover states, transitions, micro- interactions, loading states, empty states, scroll behavior, and the small details that make a site feel premium. Trigger when a site "works but feels cheap," needs "polish," or the user says "make it feel premium/professional/finished."

Web Polish349L