A fully accessible, headless modal component with compound component pattern.
- ✅ Controlled and uncontrolled modes
- ✅ Focus trap and focus management
- ✅ Body scroll lock
- ✅ Keyboard navigation (Escape to close)
- ✅ Click outside to close
- ✅ Portal rendering to document.body
- ✅ Full ARIA support
- ✅ TypeScript first
import {
TpModal,
TpModalOverlay,
TpModalContent,
TpModalClose,
TpModalTitle,
TpModalDescription,
} from '@travelopia/react-components'
function MyComponent() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<TpModal open={isOpen} onOpenChange={setIsOpen}>
<TpModalOverlay>
<TpModalContent>
<TpModalTitle>Modal Title</TpModalTitle>
<TpModalDescription>
This is the modal description.
</TpModalDescription>
<TpModalClose>
<button>Close</button>
</TpModalClose>
</TpModalContent>
</TpModalOverlay>
</TpModal>
</>
)
}<TpModal defaultOpen={false}>
<button onClick={() => {/* open logic */}}>Open</button>
<TpModalOverlay>
<TpModalContent>
<h2>Uncontrolled Modal</h2>
<TpModalClose><button>Close</button></TpModalClose>
</TpModalContent>
</TpModalOverlay>
</TpModal>import { useTpModal } from '@travelopia/react-components'
function MyComponent() {
const modal = useTpModal()
return (
<>
<button onClick={modal.open}>Open Modal</button>
<TpModal open={modal.isOpen} onOpenChange={(open) => !open && modal.close()}>
<TpModalOverlay>
<TpModalContent>
<h2>Modal Content</h2>
<button onClick={modal.close}>Close</button>
</TpModalContent>
</TpModalOverlay>
</TpModal>
</>
)
}<TpModal open={isOpen} onOpenChange={setIsOpen}>
<TpModalOverlay className="bg-black/80 backdrop-blur-sm">
<TpModalContent className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<TpModalTitle className="text-2xl font-bold mb-4">
Styled Modal
</TpModalTitle>
<TpModalDescription className="text-gray-600 mb-6">
This modal is styled with Tailwind CSS.
</TpModalDescription>
<TpModalClose className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
<span>Close</span>
</TpModalClose>
</TpModalContent>
</TpModalOverlay>
</TpModal>Root component that manages modal state.
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
- | Controlled open state |
defaultOpen |
boolean |
false |
Default open state (uncontrolled) |
onOpenChange |
(open: boolean) => void |
- | Callback when open state changes |
children |
ReactNode |
- | Modal content |
The modal backdrop/overlay.
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnClick |
boolean |
true |
Whether clicking overlay closes modal |
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Usually contains TpModalContent |
The modal dialog box.
| Prop | Type | Default | Description |
|---|---|---|---|
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Modal content |
ARIA Attributes:
role="dialog"aria-modal="true"aria-labelledby- Links to TpModalTitlearia-describedby- Links to TpModalDescription
Close button wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label |
string |
"Close modal" |
Accessible label |
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Button content |
Modal title (for accessibility).
| Prop | Type | Default | Description |
|---|---|---|---|
as |
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' |
'h2' |
Heading level |
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Title text |
Modal description (for accessibility).
| Prop | Type | Default | Description |
|---|---|---|---|
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Description text |
Custom hook to manage modal state.
const modal = useTpModal(defaultOpen?: boolean)Returns:
{
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}Example:
const modal = useTpModal(false)
<button onClick={modal.open}>Open</button>
<button onClick={modal.close}>Close</button>
<button onClick={modal.toggle}>Toggle</button>- Escape: Close modal
- Tab: Cycle through focusable elements (trapped within modal)
- Focus is automatically trapped within the modal when open
- Focus returns to the trigger element when closed
- First focusable element receives focus on open
- Proper ARIA attributes for dialog role
- Title and description are linked for screen readers
- Modal state is announced when opened/closed
- Portal rendering prevents z-index issues
- Body scroll lock prevents background scrolling
- Minimal re-renders with optimized context
- Tree-shakeable - import only what you need