Modal & Dialog Unification
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.