Modal

React Portal, focus trap, ESC to close, backdrop animation, scroll lock. Accessible dialog.

Props

PropTypeDefaultDescription
isOpenbooleanrequiredControls visibility
onClose() => voidrequiredCalled on ESC, close button, or backdrop click
titlestringrequiredModal heading, tied to aria-labelledby
childrenReactNoderequiredModal body content
size'sm' | 'md' | 'lg' | 'full''md'Max-width of the modal panel
closeOnBackdropbooleantrueWhether clicking backdrop calls onClose
showCloseButtonbooleantrueWhether 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}