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-react

Basic 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>

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#

  1. Always include a close button - Users need a clear way to dismiss modals
  2. Trap focus - Focus should stay within the modal while open
  3. Lock body scroll - Prevent background scrolling when modal is open
  4. Use portals - Render modals at the document root to avoid z-index issues
  5. Provide keyboard navigation - Support Escape key and Tab for accessibility