Modal
React Portal, focus trap, ESC to close, backdrop animation, scroll lock. Accessible dialog.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpen | boolean | required | Controls visibility |
| onClose | () => void | required | Called on ESC, close button, or backdrop click |
| title | string | required | Modal heading, tied to aria-labelledby |
| children | ReactNode | required | Modal body content |
| size | 'sm' | 'md' | 'lg' | 'full' | 'md' | Max-width of the modal panel |
| closeOnBackdrop | boolean | true | Whether clicking backdrop calls onClose |
| showCloseButton | boolean | true | Whether to render X close button in header |
Behavior
- Rendered via React Portal into document.body - avoids z-index and overflow issues
- Backdrop: semi-transparent overlay, fade-in on open
- Panel: slide-up + fade-in on open, reverse on close via onTransitionEnd (waits for animation before unmounting)
- Focus trap: on open, first focusable element receives focus. Tab cycles within the modal only.
- ESC key: calls onClose
- Scroll lock: overflow-hidden added to body on open, removed on close
Usage
'use client'
import { useState } from 'react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
export function DeleteConfirm() {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="destructive" onClick={() => setOpen(true)}>
Delete account
</Button>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
title="Delete account"
size="sm"
>
<p>This action cannot be undone.</p>
<div className="flex gap-3 mt-6">
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Yes, delete
</Button>
</div>
</Modal>
</>
)
}Accessibility
- role='dialog' aria-modal='true' on the panel
- aria-labelledby tied to the title heading id
- Focus trapped inside - Tab and Shift+Tab cycle within the modal
- ESC key closes - standard keyboard pattern for dialogs
- Backdrop click closes by default - disable with closeOnBackdrop={false}