Modal Patterns
Build accessible modals, dialogs, and drawers using Radix UI primitives.
Overview#
Modals are essential for focused interactions. This pattern covers:
- Basic modal with portal rendering
- Radix Dialog for accessibility
- Confirmation dialogs
- Sheets and drawers
- Form modals
Prerequisites#
npm install @radix-ui/react-dialog lucide-reactBasic Modal#
A custom modal with keyboard and click-outside handling.
1// components/ui/Modal.tsx
2'use client'
3
4import { useEffect, useRef } from 'react'
5import { createPortal } from 'react-dom'
6import { X } from 'lucide-react'
7
8interface Props {
9 isOpen: boolean
10 onClose: () => void
11 title: string
12 children: React.ReactNode
13}
14
15export function Modal({ isOpen, onClose, title, children }: Props) {
16 const overlayRef = useRef<HTMLDivElement>(null)
17
18 // Close on escape
19 useEffect(() => {
20 function handleEscape(e: KeyboardEvent) {
21 if (e.key === 'Escape') onClose()
22 }
23
24 if (isOpen) {
25 document.addEventListener('keydown', handleEscape)
26 document.body.style.overflow = 'hidden'
27 }
28
29 return () => {
30 document.removeEventListener('keydown', handleEscape)
31 document.body.style.overflow = ''
32 }
33 }, [isOpen, onClose])
34
35 // Close on overlay click
36 function handleOverlayClick(e: React.MouseEvent) {
37 if (e.target === overlayRef.current) onClose()
38 }
39
40 if (!isOpen) return null
41
42 return createPortal(
43 <div
44 ref={overlayRef}
45 onClick={handleOverlayClick}
46 className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
47 role="dialog"
48 aria-modal="true"
49 aria-labelledby="modal-title"
50 >
51 <div className="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
52 <div className="mb-4 flex items-center justify-between">
53 <h2 id="modal-title" className="text-xl font-semibold">
54 {title}
55 </h2>
56 <button
57 onClick={onClose}
58 className="rounded p-1 hover:bg-gray-100"
59 aria-label="Close modal"
60 >
61 <X className="h-5 w-5" />
62 </button>
63 </div>
64 {children}
65 </div>
66 </div>,
67 document.body
68 )
69}Radix Dialog#
Use Radix UI for built-in accessibility and animations.
1// components/ui/Dialog.tsx
2'use client'
3
4import * as DialogPrimitive from '@radix-ui/react-dialog'
5import { X } from 'lucide-react'
6
7export function Dialog({ children, ...props }: DialogPrimitive.DialogProps) {
8 return <DialogPrimitive.Root {...props}>{children}</DialogPrimitive.Root>
9}
10
11export const DialogTrigger = DialogPrimitive.Trigger
12
13export function DialogContent({
14 children,
15 title,
16 description
17}: {
18 children: React.ReactNode
19 title: string
20 description?: string
21}) {
22 return (
23 <DialogPrimitive.Portal>
24 <DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-fadeIn" />
25 <DialogPrimitive.Content className="fixed left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl data-[state=open]:animate-scaleIn">
26 <DialogPrimitive.Title className="text-xl font-semibold">
27 {title}
28 </DialogPrimitive.Title>
29
30 {description && (
31 <DialogPrimitive.Description className="mt-2 text-gray-600">
32 {description}
33 </DialogPrimitive.Description>
34 )}
35
36 <div className="mt-4">{children}</div>
37
38 <DialogPrimitive.Close className="absolute right-4 top-4 rounded p-1 hover:bg-gray-100">
39 <X className="h-5 w-5" />
40 </DialogPrimitive.Close>
41 </DialogPrimitive.Content>
42 </DialogPrimitive.Portal>
43 )
44}
45
46// Usage
47<Dialog>
48 <DialogTrigger asChild>
49 <button>Open Dialog</button>
50 </DialogTrigger>
51 <DialogContent title="Edit Profile">
52 <form>...</form>
53 </DialogContent>
54</Dialog>Confirmation Dialog#
A reusable confirmation dialog for destructive actions.
1// components/ui/ConfirmDialog.tsx
2'use client'
3
4import { useState } from 'react'
5import { Dialog, DialogContent, DialogTrigger } from './Dialog'
6
7interface Props {
8 title: string
9 message: string
10 confirmText?: string
11 cancelText?: string
12 variant?: 'danger' | 'warning' | 'default'
13 onConfirm: () => void | Promise<void>
14 trigger: React.ReactNode
15}
16
17export function ConfirmDialog({
18 title,
19 message,
20 confirmText = 'Confirm',
21 cancelText = 'Cancel',
22 variant = 'default',
23 onConfirm,
24 trigger
25}: Props) {
26 const [open, setOpen] = useState(false)
27 const [loading, setLoading] = useState(false)
28
29 async function handleConfirm() {
30 setLoading(true)
31 try {
32 await onConfirm()
33 setOpen(false)
34 } finally {
35 setLoading(false)
36 }
37 }
38
39 const confirmClass = {
40 danger: 'bg-red-600 hover:bg-red-700',
41 warning: 'bg-amber-600 hover:bg-amber-700',
42 default: 'bg-black hover:bg-gray-800'
43 }[variant]
44
45 return (
46 <Dialog open={open} onOpenChange={setOpen}>
47 <DialogTrigger asChild>{trigger}</DialogTrigger>
48 <DialogContent title={title} description={message}>
49 <div className="mt-6 flex justify-end gap-3">
50 <button
51 onClick={() => setOpen(false)}
52 className="rounded px-4 py-2 hover:bg-gray-100"
53 >
54 {cancelText}
55 </button>
56 <button
57 onClick={handleConfirm}
58 disabled={loading}
59 className={`rounded px-4 py-2 text-white ${confirmClass}`}
60 >
61 {loading ? 'Loading...' : confirmText}
62 </button>
63 </div>
64 </DialogContent>
65 </Dialog>
66 )
67}
68
69// Usage
70<ConfirmDialog
71 title="Delete Item"
72 message="Are you sure you want to delete this item? This action cannot be undone."
73 confirmText="Delete"
74 variant="danger"
75 onConfirm={() => deleteItem(id)}
76 trigger={<button>Delete</button>}
77/>Sheet / Drawer#
Slide-in panels for navigation or forms.
1// components/ui/Sheet.tsx
2'use client'
3
4import * as SheetPrimitive from '@radix-ui/react-dialog'
5import { X } from 'lucide-react'
6
7type Side = 'left' | 'right' | 'top' | 'bottom'
8
9interface Props {
10 children: React.ReactNode
11 side?: Side
12 title: string
13}
14
15const sideClasses: Record<Side, string> = {
16 left: 'left-0 top-0 h-full w-80 data-[state=open]:animate-slideInLeft',
17 right: 'right-0 top-0 h-full w-80 data-[state=open]:animate-slideInRight',
18 top: 'top-0 left-0 w-full h-80 data-[state=open]:animate-slideInTop',
19 bottom: 'bottom-0 left-0 w-full h-80 data-[state=open]:animate-slideInBottom'
20}
21
22export const Sheet = SheetPrimitive.Root
23export const SheetTrigger = SheetPrimitive.Trigger
24
25export function SheetContent({ children, side = 'right', title }: Props) {
26 return (
27 <SheetPrimitive.Portal>
28 <SheetPrimitive.Overlay className="fixed inset-0 bg-black/50" />
29 <SheetPrimitive.Content
30 className={`fixed bg-white p-6 shadow-xl ${sideClasses[side]}`}
31 >
32 <div className="mb-4 flex items-center justify-between">
33 <SheetPrimitive.Title className="text-lg font-semibold">
34 {title}
35 </SheetPrimitive.Title>
36 <SheetPrimitive.Close className="rounded p-1 hover:bg-gray-100">
37 <X className="h-5 w-5" />
38 </SheetPrimitive.Close>
39 </div>
40 {children}
41 </SheetPrimitive.Content>
42 </SheetPrimitive.Portal>
43 )
44}
45
46// Usage
47<Sheet>
48 <SheetTrigger asChild>
49 <button>Open Menu</button>
50 </SheetTrigger>
51 <SheetContent side="left" title="Navigation">
52 <nav>...</nav>
53 </SheetContent>
54</Sheet>Modal with Form#
Handle form submission within a modal.
1// components/modals/EditUserModal.tsx
2'use client'
3
4import { useState } from 'react'
5import { useForm } from 'react-hook-form'
6import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/Dialog'
7
8interface Props {
9 user: User
10 onUpdate: (data: UserData) => Promise<void>
11}
12
13export function EditUserModal({ user, onUpdate }: Props) {
14 const [open, setOpen] = useState(false)
15 const { register, handleSubmit, reset, formState: { isSubmitting } } = useForm({
16 defaultValues: { name: user.name, email: user.email }
17 })
18
19 async function onSubmit(data: UserData) {
20 await onUpdate(data)
21 setOpen(false)
22 reset(data)
23 }
24
25 return (
26 <Dialog open={open} onOpenChange={setOpen}>
27 <DialogTrigger asChild>
28 <button>Edit</button>
29 </DialogTrigger>
30 <DialogContent title="Edit User">
31 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
32 <div>
33 <label>Name</label>
34 <input {...register('name')} className="w-full rounded border px-3 py-2" />
35 </div>
36 <div>
37 <label>Email</label>
38 <input {...register('email')} type="email" className="w-full rounded border px-3 py-2" />
39 </div>
40 <div className="flex justify-end gap-2">
41 <button type="button" onClick={() => setOpen(false)}>
42 Cancel
43 </button>
44 <button type="submit" disabled={isSubmitting} className="rounded bg-black px-4 py-2 text-white">
45 {isSubmitting ? 'Saving...' : 'Save'}
46 </button>
47 </div>
48 </form>
49 </DialogContent>
50 </Dialog>
51 )
52}Best Practices#
- Always include a close button - Users need a clear way to dismiss modals
- Trap focus - Focus should stay within the modal while open
- Lock body scroll - Prevent background scrolling when modal is open
- Use portals - Render modals at the document root to avoid z-index issues
- Provide keyboard navigation - Support Escape key and Tab for accessibility