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