Modal Dialog System
Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a
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 linesModal & 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
- 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.
Install this skill directly: skilldb add web-polish-skills
Related Skills
Color System Repair
Fix color chaos in a vibecoded website — too many near-duplicate colors, inconsistent
Component Unification
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 Extraction
Extract a unified set of design tokens (colors, typography, spacing, shadows, radii) from a
Form Elements
Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,
Navigation Patterns
Fix inconsistent navigation patterns — mismatched headers, footers, sidebars, breadcrumbs,