Modal
A modal is an overlay element which blocks interaction with elements outside it.
install | yarn add react-aria-components |
---|---|
version | 3.17.0 |
usage | import {Modal} from 'react-aria-components' |
Example#
import {Button, Dialog, DialogTrigger, Heading, Input, Label, Modal, TextField} from 'react-aria-components';
<DialogTrigger>
<Button>Sign up…</Button>
<Modal>
<Dialog>
{({ close }) => (
<form>
<Heading>Sign up</Heading>
<TextField autoFocus>
<Label>First Name:</Label>
<Input />
</TextField>
<TextField>
<Label>Last Name:</Label>
<Input />
</TextField>
<Button onPress={close}>
Submit
</Button>
</form>
)}
</Dialog>
</Modal>
</DialogTrigger>
import {
Button,
Dialog,
DialogTrigger,
Heading,
Input,
Label,
Modal,
TextField
} from 'react-aria-components';
<DialogTrigger>
<Button>Sign up…</Button>
<Modal>
<Dialog>
{({ close }) => (
<form>
<Heading>Sign up</Heading>
<TextField autoFocus>
<Label>First Name:</Label>
<Input />
</TextField>
<TextField>
<Label>Last Name:</Label>
<Input />
</TextField>
<Button onPress={close}>
Submit
</Button>
</form>
)}
</Dialog>
</Modal>
</DialogTrigger>
import {
Button,
Dialog,
DialogTrigger,
Heading,
Input,
Label,
Modal,
TextField
} from 'react-aria-components';
<DialogTrigger>
<Button>
Sign up…
</Button>
<Modal>
<Dialog>
{({ close }) => (
<form>
<Heading>
Sign up
</Heading>
<TextField
autoFocus
>
<Label>
First
Name:
</Label>
<Input />
</TextField>
<TextField>
<Label>
Last
Name:
</Label>
<Input />
</TextField>
<Button
onPress={close}
>
Submit
</Button>
</form>
)}
</Dialog>
</Modal>
</DialogTrigger>
Show CSS
.react-aria-ModalOverlay {
position: fixed;
inset: 0;
background: rgba(0 0 0 / .5);
display: flex;
align-items: center;
justify-content: center;
&[data-entering] {
animation: fade 200ms;
}
&[data-exiting] {
animation: fade 150ms reverse ease-in;
}
}
.react-aria-Modal {
box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
border-radius: 6px;
background: var(--page-background);
border: 1px solid var(--spectrum-global-color-gray-300);
outline: none;
padding: 30px;
max-width: 250px;
&[data-entering] {
animation: zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes zoom {
from {
transform: scale(0.8);
}
to {
transform: scale(1);
}
}
.react-aria-Dialog {
outline: none;
& h2 {
line-height: 1em;
margin-top: 0;
}
& button {
margin-top: 20px;
}
}
.react-aria-Button {
background: var(--spectrum-global-color-gray-50);
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
appearance: none;
vertical-align: middle;
font-size: 1.2rem;
text-align: center;
margin: 0;
outline: none;
padding: 6px;
transition: border-color 200ms;
&[data-hovered] {
border-color: var(--spectrum-global-color-gray-500);
}
&[data-pressed] {
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
background: var(--spectrum-global-color-gray-100);
border-color: var(--spectrum-global-color-gray-600);
}
&[data-focus-visible] {
border-color: slateblue;
box-shadow: 0 0 0 1px slateblue;
}
}
.react-aria-TextField {
margin-bottom: 8px;
& label {
display: inline-block;
width: 75px;
}
}
.react-aria-ModalOverlay {
position: fixed;
inset: 0;
background: rgba(0 0 0 / .5);
display: flex;
align-items: center;
justify-content: center;
&[data-entering] {
animation: fade 200ms;
}
&[data-exiting] {
animation: fade 150ms reverse ease-in;
}
}
.react-aria-Modal {
box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
border-radius: 6px;
background: var(--page-background);
border: 1px solid var(--spectrum-global-color-gray-300);
outline: none;
padding: 30px;
max-width: 250px;
&[data-entering] {
animation: zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes zoom {
from {
transform: scale(0.8);
}
to {
transform: scale(1);
}
}
.react-aria-Dialog {
outline: none;
& h2 {
line-height: 1em;
margin-top: 0;
}
& button {
margin-top: 20px;
}
}
.react-aria-Button {
background: var(--spectrum-global-color-gray-50);
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
appearance: none;
vertical-align: middle;
font-size: 1.2rem;
text-align: center;
margin: 0;
outline: none;
padding: 6px;
transition: border-color 200ms;
&[data-hovered] {
border-color: var(--spectrum-global-color-gray-500);
}
&[data-pressed] {
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
background: var(--spectrum-global-color-gray-100);
border-color: var(--spectrum-global-color-gray-600);
}
&[data-focus-visible] {
border-color: slateblue;
box-shadow: 0 0 0 1px slateblue;
}
}
.react-aria-TextField {
margin-bottom: 8px;
& label {
display: inline-block;
width: 75px;
}
}
.react-aria-ModalOverlay {
position: fixed;
inset: 0;
background: rgba(0 0 0 / .5);
display: flex;
align-items: center;
justify-content: center;
&[data-entering] {
animation: fade 200ms;
}
&[data-exiting] {
animation: fade 150ms reverse ease-in;
}
}
.react-aria-Modal {
box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
border-radius: 6px;
background: var(--page-background);
border: 1px solid var(--spectrum-global-color-gray-300);
outline: none;
padding: 30px;
max-width: 250px;
&[data-entering] {
animation: zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes zoom {
from {
transform: scale(0.8);
}
to {
transform: scale(1);
}
}
.react-aria-Dialog {
outline: none;
& h2 {
line-height: 1em;
margin-top: 0;
}
& button {
margin-top: 20px;
}
}
.react-aria-Button {
background: var(--spectrum-global-color-gray-50);
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
appearance: none;
vertical-align: middle;
font-size: 1.2rem;
text-align: center;
margin: 0;
outline: none;
padding: 6px;
transition: border-color 200ms;
&[data-hovered] {
border-color: var(--spectrum-global-color-gray-500);
}
&[data-pressed] {
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.1);
background: var(--spectrum-global-color-gray-100);
border-color: var(--spectrum-global-color-gray-600);
}
&[data-focus-visible] {
border-color: slateblue;
box-shadow: 0 0 0 1px slateblue;
}
}
.react-aria-TextField {
margin-bottom: 8px;
& label {
display: inline-block;
width: 75px;
}
}
Features#
The HTML <dialog> element
can be used to build modals. However, it is not yet widely supported across browsers, and
building fully accessible custom dialogs from scratch is very difficult and error prone.
Modal
helps achieve accessible modals that can be styled as needed.
- Styleable – States for entry and exit animations are included for easy styling. Both the underlay and overlay elements can be customized.
- Accessible – Content outside the model is hidden from assistive technologies while it is open. The modal optionally closes when interacting outside, or pressing the Escape key.
- Focus management – Focus is moved into the modal on mount, and restored to the trigger element on unmount. While open, focus is contained within the modal, preventing the user from tabbing outside.
- Scroll locking – Scrolling the page behind the modal is prevented while it is open, including in mobile browsers.
Note: Modal
only provides the overlay itself. It should be combined with Dialog to create fully accessible modal dialogs. Other overlays such as menus may also be placed in a modal overlay.
Anatomy#
A modal consists of an overlay container element, and an underlay. The overlay may contain a Dialog, or another element such as a Menu or ListBox when used within a component such as a Select or ComboBox. The underlay is typically a partially transparent element that covers the rest of the screen behind the overlay, and prevents the user from interacting with the elements behind it.
Props#
Name | Type | Default | Description |
isDismissable | boolean | false | Whether to close the modal when the user interacts outside it. |
isKeyboardDismissDisabled | boolean | false | Whether pressing the escape key to close the modal should be disabled. |
isOpen | boolean | — | Whether the overlay is open by default (controlled). |
defaultOpen | boolean | — | Whether the overlay is open by default (uncontrolled). |
children | ReactNode | (
(values: ModalRenderProps
)) => ReactNode | — | |
className | string | (
(values: ModalRenderProps
)) => string | — | |
style | CSSProperties | (
(values: ModalRenderProps
)) => CSSProperties | — |
Events
Name | Type | Default | Description |
onOpenChange | (
(isOpen: boolean
)) => void | — | Handler that is called when the overlay's open state changes. |
Styling#
React Aria components can be styled in many ways, including using CSS classes, inline styles, utility classes (e.g. Tailwind), CSS-in-JS (e.g. Styled Components), etc. By default, all components include a builtin className
attribute which can be targeted using CSS selectors. These follow the react-aria-ComponentName
naming convention.
.react-aria-Modal {
/* ... */
}
.react-aria-Modal {
/* ... */
}
.react-aria-Modal {
/* ... */
}
A custom className
can also be specified on any component. This overrides the default className
provided by React Aria with your own.
<Modal className="my-modal">
{/* ... */}
</Modal>
<Modal className="my-modal">
{/* ... */}
</Modal>
<Modal className="my-modal">
{/* ... */}
</Modal>;
In addition, modals support entry and exit animations, which are exposed as states using DOM attributes that you can target with CSS selectors. Modal
and ModalOverlay
will automatically wait for any exit animations to complete before removing the element from the DOM.
.react-aria-Modal[data-entering] {
animation: slide 300ms;
}
.react-aria-Modal[data-exiting] {
animation: slide 300ms reverse;
}
@keyframes slide {
/* ... */
}
.react-aria-Modal[data-entering] {
animation: slide 300ms;
}
.react-aria-Modal[data-exiting] {
animation: slide 300ms reverse;
}
@keyframes slide {
/* ... */
}
.react-aria-Modal[data-entering] {
animation: slide 300ms;
}
.react-aria-Modal[data-exiting] {
animation: slide 300ms reverse;
}
@keyframes slide {
/* ... */
}
The className
and style
props also accept functions which receive states for styling. This lets you dynamically determine the classes or styles to apply, which is useful when using utility CSS libraries like Tailwind.
<Modal className={({isEntering}) => isEntering ? 'slide-in' : ''}>
{/* ... */}
</Modal>
<Modal
className={({ isEntering }) =>
isEntering ? 'slide-in' : ''}
>
{/* ... */}
</Modal>;
<Modal
className={(
{ isEntering }
) =>
isEntering
? 'slide-in'
: ''}
>
{/* ... */}
</Modal>;
The states, selectors, and render props for each component used in a Modal
are documented below.
Modal#
A Modal
can be targeted with the .react-aria-Modal
CSS selector, or by overriding with a custom className
. It supports the following states and render props:
Name | CSS Selector | Description |
isEntering | [data-entering] | Whether the modal is currently entering. Use this to apply animations. |
isExiting | [data-exiting] | Whether the modal is currently exiting. Use this to apply animations. |
ModalOverlay#
By default, Modal
includes a builtin ModalOverlay
, which renders a backdrop over the page when a modal is open. This can be targeted using the .react-aria-ModalOverlay
CSS selector. To customize the ModalOverlay
with a different class name or other attributes, render a ModalOverlay
and place a Modal
inside.
This example also shows how to use a Modal
to render other types of overlays such as a tray or drawer, as well as support for custom entry and exit animations.
import {ModalOverlay} from 'react-aria-components';
<DialogTrigger>
<Button>Open modal</Button>
<ModalOverlay className="my-overlay">
<Modal className="my-modal">
<Dialog>
{({close}) => <>
<Heading>Notice</Heading>
<p>This is a modal with a custom modal overlay.</p>
<Button onPress={close}>Close</Button>
</>}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
import {ModalOverlay} from 'react-aria-components';
<DialogTrigger>
<Button>Open modal</Button>
<ModalOverlay className="my-overlay">
<Modal className="my-modal">
<Dialog>
{({ close }) => (
<>
<Heading>Notice</Heading>
<p>
This is a modal with a custom modal overlay.
</p>
<Button onPress={close}>Close</Button>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
import {ModalOverlay} from 'react-aria-components';
<DialogTrigger>
<Button>
Open modal
</Button>
<ModalOverlay className="my-overlay">
<Modal className="my-modal">
<Dialog>
{(
{ close }
) => (
<>
<Heading>
Notice
</Heading>
<p>
This is a
modal
with a
custom
modal
overlay.
</p>
<Button
onPress={close}
>
Close
</Button>
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
Show CSS
.my-overlay {
position: fixed;
inset: 0;
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
&[data-entering] {
animation: blur 300ms;
}
&[data-exiting] {
animation: blur 300ms reverse ease-in;
}
}
.my-modal {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: 250px;
background: var(--page-background);
outline: none;
padding: 30px;
border-left: 1px solid var(--spectrum-global-color-gray-400);
box-shadow: -8px 0 20px rgba(0 0 0 / 0.1);
&[data-entering] {
animation: slide 300ms;
}
&[data-exiting] {
animation: slide 300ms reverse ease-in;
}
}
@keyframes blur {
from {
background: rgba(45 0 0 / 0);
backdrop-filter: blur(0);
}
to {
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
}
}
@keyframes slide {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.my-overlay {
position: fixed;
inset: 0;
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
&[data-entering] {
animation: blur 300ms;
}
&[data-exiting] {
animation: blur 300ms reverse ease-in;
}
}
.my-modal {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: 250px;
background: var(--page-background);
outline: none;
padding: 30px;
border-left: 1px solid var(--spectrum-global-color-gray-400);
box-shadow: -8px 0 20px rgba(0 0 0 / 0.1);
&[data-entering] {
animation: slide 300ms;
}
&[data-exiting] {
animation: slide 300ms reverse ease-in;
}
}
@keyframes blur {
from {
background: rgba(45 0 0 / 0);
backdrop-filter: blur(0);
}
to {
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
}
}
@keyframes slide {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.my-overlay {
position: fixed;
inset: 0;
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
&[data-entering] {
animation: blur 300ms;
}
&[data-exiting] {
animation: blur 300ms reverse ease-in;
}
}
.my-modal {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: 250px;
background: var(--page-background);
outline: none;
padding: 30px;
border-left: 1px solid var(--spectrum-global-color-gray-400);
box-shadow: -8px 0 20px rgba(0 0 0 / 0.1);
&[data-entering] {
animation: slide 300ms;
}
&[data-exiting] {
animation: slide 300ms reverse ease-in;
}
}
@keyframes blur {
from {
background: rgba(45 0 0 / 0);
backdrop-filter: blur(0);
}
to {
background: rgba(45 0 0 / .3);
backdrop-filter: blur(10px);
}
}
@keyframes slide {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
Usage#
Dismissable#
If your modal doesn't require the user to make a confirmation, you can set isDismissable
on the Modal
. This allows the user to click outside to close the dialog.
<DialogTrigger>
<Button>Open dialog</Button>
<Modal isDismissable>
<Dialog>
<Heading>Notice</Heading>
<p>Click outside to close this dialog.</p>
</Dialog>
</Modal>
</DialogTrigger>
<DialogTrigger>
<Button>Open dialog</Button>
<Modal isDismissable>
<Dialog>
<Heading>Notice</Heading>
<p>Click outside to close this dialog.</p>
</Dialog>
</Modal>
</DialogTrigger>
<DialogTrigger>
<Button>
Open dialog
</Button>
<Modal isDismissable>
<Dialog>
<Heading>
Notice
</Heading>
<p>
Click outside
to close this
dialog.
</p>
</Dialog>
</Modal>
</DialogTrigger>
Keyboard dismiss disabled#
By default, modals can be closed by pressing the Escape key. This can be disabled with the isKeyboardDismissDisabled
prop.
<DialogTrigger>
<Button>Open dialog</Button>
<Modal isKeyboardDismissDisabled>
<Dialog>
{({close}) => <>
<Heading>Notice</Heading>
<p>You must close this dialog using the button below.</p>
<Button onPress={close}>Close</Button>
</>}
</Dialog>
</Modal>
</DialogTrigger>
<DialogTrigger>
<Button>Open dialog</Button>
<Modal isKeyboardDismissDisabled>
<Dialog>
{({ close }) => (
<>
<Heading>Notice</Heading>
<p>
You must close this dialog using the button
below.
</p>
<Button onPress={close}>Close</Button>
</>
)}
</Dialog>
</Modal>
</DialogTrigger>
<DialogTrigger>
<Button>
Open dialog
</Button>
<Modal
isKeyboardDismissDisabled
>
<Dialog>
{({ close }) => (
<>
<Heading>
Notice
</Heading>
<p>
You must
close this
dialog
using the
button
below.
</p>
<Button
onPress={close}
>
Close
</Button>
</>
)}
</Dialog>
</Modal>
</DialogTrigger>
Controlled open state#
The above examples have shown Modal
used within a <DialogTrigger>
, which handles opening the modal when a button is clicked. This is convenient, but there are cases where you want to show a modal programmatically rather than as a result of a user action, or render the <Modal>
in a different part of the JSX tree.
To do this, you can manage the modal's isOpen
state yourself and provide it as a prop to the <Modal>
element. The onOpenChange
prop will be called when the user closes the modal, and should be used to update your state.
function Example() {
let [isOpen, setOpen] = React.useState(false);
return (
<>
<Button onPress={() => setOpen(true)}>Open dialog</Button>
<Modal isDismissable isOpen={isOpen} onOpenChange={setOpen}>
<Dialog>
<Heading>Notice</Heading>
<p>Click outside to close this dialog.</p>
</Dialog>
</Modal>
</>
);
}
function Example() {
let [isOpen, setOpen] = React.useState(false);
return (
<>
<Button onPress={() => setOpen(true)}>
Open dialog
</Button>
<Modal
isDismissable
isOpen={isOpen}
onOpenChange={setOpen}
>
<Dialog>
<Heading>Notice</Heading>
<p>Click outside to close this dialog.</p>
</Dialog>
</Modal>
</>
);
}
function Example() {
let [isOpen, setOpen] =
React.useState(
false
);
return (
<>
<Button
onPress={() =>
setOpen(true)}
>
Open dialog
</Button>
<Modal
isDismissable
isOpen={isOpen}
onOpenChange={setOpen}
>
<Dialog>
<Heading>
Notice
</Heading>
<p>
Click outside
to close this
dialog.
</p>
</Dialog>
</Modal>
</>
);
}
Advanced customization#
Hooks#
If you need to customize things further, such as accessing internal state or customizing DOM structure, you can drop down to the lower level Hook-based API. See useModalOverlay for more details.