Modal component
Modal is a focus-trapping, portal-rendered dialog for content that needs the user's full attention before they continue — confirmations, forms that block the underlying page, detail views opened from a list. It's fully controlled: you own the open/closed state, Modal just renders it correctly and safely.
Playground
Change a prop on the right and the preview updates. Switch to Code to copy what you see.
Recipes
Copy-paste patterns for the ways Modal actually gets used — each one a real, self-contained example.
Confirmation dialog
Modal is fully controlled — you own the open state. title is the header, children the body, actions the footer. onClose fires from every dismissal path.
const [isOpen, setIsOpen] = useState(false);
<Button onPress={() => setIsOpen(true)}>Open</Button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Publish changes"
actions={
<>
<Button variant="soft" onPress={() => setIsOpen(false)}>Cancel</Button>
<Button color="primary" onPress={() => setIsOpen(false)}>Publish</Button>
</>
}
>
Your changes will go live immediately.
</Modal>Destructive confirm
For a decision the user must make, set role="alertdialog" so screen readers announce it assertively, and lead with a danger action.
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
role="alertdialog"
title="Delete project"
actions={
<>
<Button variant="soft" onPress={() => setIsOpen(false)}>Cancel</Button>
<Button color="danger" onPress={handleDelete}>Delete</Button>
</>
}
>
This can't be undone. All data will be permanently removed.
</Modal>Sizes
size takes the token scale plus "full" for edge-to-edge — or a raw width. Pick by how much the content needs.
<Modal isOpen={isOpen} onClose={onClose} size="sm" title="Small">…</Modal>
<Modal isOpen={isOpen} onClose={onClose} size="lg" title="Large">…</Modal>
<Modal isOpen={isOpen} onClose={onClose} size="full" title="Full screen">…</Modal>Block accidental dismissal
For a multi-step or destructive flow, set closeOnOutsideClick={false} so a stray click on the backdrop can't lose the user's progress.
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
closeOnOutsideClick={false}
title="Complete setup"
>
Finish these steps before closing.
</Modal>Controlled open state
isOpen and onClose are both required — Modal never manages its own visibility. onClose fires from every dismissal path (backdrop click, Escape, the header's close button) so there's exactly one place to update your state, regardless of how the user closed it.
const DeleteConfirm = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button color="danger" onPress={() => setIsOpen(true)}>Delete item</Button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Delete item"
actions={
<>
<Button variant="soft" onPress={() => setIsOpen(false)}>Cancel</Button>
<Button color="danger" onPress={handleDelete}>Delete</Button>
</>
}
>
This can't be undone. Are you sure?
</Modal>
</>
);
};Header, body, footer
title renders the header (built on PageTitle internally, with a close button next to it unless dismissible={false}), children is the body, and actions is the footer — typically Cancel/Confirm-style buttons. All three are optional independently, so a Modal can be body-only, or header+body with no footer.
Safe stacking
Opening a second Modal (or a Drawer, since they share the same overlay stack) while one is already open works correctly out of the box — z-index and backdrop layering are computed automatically, and Escape only ever closes the topmost overlay, not every open one at once.
// Nested confirm-inside-a-modal -- stacks correctly without manual z-index math
<Modal isOpen={editOpen} onClose={() => setEditOpen(false)} title="Edit profile">
<ProfileForm />
<Modal
isOpen={confirmDiscard}
onClose={() => setConfirmDiscard(false)}
title="Discard changes?"
role="alertdialog"
>
Unsaved changes will be lost.
</Modal>
</Modal>Sizing and animation
size accepts the usual token scale plus "full" for an edge-to-edge modal, or a raw string/number for an exact width. animationType controls how it enters/exits (fade, zoom, or a directional slide) with animationDuration/easing to fine-tune the feel; blur and backgroundOpacity control the backdrop underneath it.
<Modal isOpen={isOpen} onClose={onClose} size="lg" animationType="slideUp" title="Details">
{content}
</Modal>Confirmation dialogs: role="alertdialog"
For a modal whose sole purpose is a decision the user must make (not just informational content), set role="alertdialog" instead of the default "dialog" — screen readers announce it more assertively, matching the native alertdialog semantics.
Props
The full public API for Modal. Every prop is stable and intentionally typed.
Content
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Header title. |
| children | ReactNode | — | Body content. |
| actions | ReactNode | — | Footer action buttons. |
title
ReactNode
default: —
Header title.
children
ReactNode
default: —
Body content.
actions
ReactNode
default: —
Footer action buttons.
Appearance
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | "xl" | "full" | string | number | md | Modal width. |
| blur | boolean | "sm" | "md" | "lg" | number | — | Backdrop blur amount. |
| backgroundOpacity | number | — | Backdrop opacity (0-100). |
size
"xs" | "sm" | "md" | "lg" | "xl" | "full" | string | number
default: md
Modal width.
blur
boolean | "sm" | "md" | "lg" | number
default: —
Backdrop blur amount.
backgroundOpacity
number
default: —
Backdrop opacity (0-100).
Layout & positioning
| Prop | Type | Default | Description |
|---|---|---|---|
| portal | boolean | true | Renders through a React portal. |
| container | Element | DocumentFragment | null | — | Custom portal mount target. |
| zIndex | number | — | Explicit stacking z-index; auto-computed when unset. |
portal
boolean
default: true
Renders through a React portal.
container
Element | DocumentFragment | null
default: —
Custom portal mount target.
zIndex
number
default: —
Explicit stacking z-index; auto-computed when unset.
Behavior & states
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpen | boolean | — | Controls whether the modal is open (required). |
| role | "dialog" | "alertdialog" | dialog | ARIA role. |
| dismissible | boolean | true | Shows a close button in the header. |
| closeLabel | string | "Close modal" | Accessible label for the close button. |
| closeOnOutsideClick | boolean | true | Closes on backdrop click. |
| closeOnEscape | boolean | true | Closes on Escape. |
| initialFocusRef | RefObject<HTMLElement | null> | — | Element to focus when the modal opens. |
isOpen
boolean
default: —
Controls whether the modal is open (required).
role
"dialog" | "alertdialog"
default: dialog
ARIA role.
dismissible
boolean
default: true
Shows a close button in the header.
closeLabel
string
default: "Close modal"
Accessible label for the close button.
closeOnOutsideClick
boolean
default: true
Closes on backdrop click.
closeOnEscape
boolean
default: true
Closes on Escape.
initialFocusRef
RefObject<HTMLElement | null>
default: —
Element to focus when the modal opens.
Motion
| Prop | Type | Default | Description |
|---|---|---|---|
| animationDuration | number | — | Enter/exit animation duration in seconds. |
| animationType | "fade" | "zoom" | "slideUp" | "slideDown" | "slideLeft" | "slideRight" | — | Backdrop entrance/exit animation. |
| easing | "ease" | "easeIn" | "easeOut" | "easeInOut" | — | Animation easing curve. |
animationDuration
number
default: —
Enter/exit animation duration in seconds.
animationType
"fade" | "zoom" | "slideUp" | "slideDown" | "slideLeft" | "slideRight"
default: —
Backdrop entrance/exit animation.
easing
"ease" | "easeIn" | "easeOut" | "easeInOut"
default: —
Animation easing curve.
Events
| Prop | Type | Default | Description |
|---|---|---|---|
| onClose | () => void | — | Called to close the modal (required). |
onClose
() => void
default: —
Called to close the modal (required).
Styling
| Prop | Type | Default | Description |
|---|---|---|---|
| backdrop | Partial<BackdropProps> | — | Extra props forwarded to the underlying Backdrop. |
| headers | Partial<PageTitleProps> | — | Extra props forwarded to the header's PageTitle. |
| divider | Partial<DividerProps> | — | Extra props for the header/body divider. |
| classNames | Partial<Record<ModalSlot, string>> | — | Per-slot class overrides (base/header/headerTitle/body/footer/closeButton). |
| className | string | — | Class applied to the root modal element. |
| style | CSSProperties | — | Inline styles for the root element. |
backdrop
Partial<BackdropProps>
default: —
Extra props forwarded to the underlying Backdrop.
headers
Partial<PageTitleProps>
default: —
Extra props forwarded to the header's PageTitle.
divider
Partial<DividerProps>
default: —
Extra props for the header/body divider.
classNames
Partial<Record<ModalSlot, string>>
default: —
Per-slot class overrides (base/header/headerTitle/body/footer/closeButton).
className
string
default: —
Class applied to the root modal element.
style
CSSProperties
default: —
Inline styles for the root element.
Accessibility
- Traps focus inside the modal while open, and restores focus to whatever triggered it on close — a keyboard user never gets dropped behind the modal into the rest of the page.
- closeOnEscape (on by default) closes the modal on Escape; when overlays are stacked, Escape only ever closes the topmost one.
- initialFocusRef lets you send initial focus somewhere other than the default (the modal itself, or its first focusable element) — useful when a specific field should be focused immediately, like a text input in a form modal.
- The header's close button has an accessible name via closeLabel (defaults to "Close modal") — override it if the default doesn't read naturally for a specific modal's content.
Best practices
- Use role="alertdialog" for confirmations and destructive actions, and leave the default role="dialog" for general content modals.
- Set closeOnOutsideClick={false} for a modal representing a multi-step or destructive flow the user shouldn't be able to dismiss by an accidental click outside it.
- Keep actions to one or two buttons — a modal footer with many actions usually means the content itself should be a full page instead.
- For content that slides in from an edge rather than appearing centered (a filters panel, a details sidebar), reach for Drawer instead — it shares Modal's focus-trapping and stacking behavior.
- Don't nest more than two modals deep in practice, even though stacking is supported — each additional layer makes it harder for the user to track their way back out.