Dialog (Modal)
Accessible Dialog
component that follows the WAI-ARIA Dialog (Modal) Pattern. It's rendered within a Portal by default, but it also has a non-modal state, which doesn't use portals.
#Installation
npm install reakit
Learn more in Get started.
#Usage
import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; function Example() { const dialog = useDialogState(); return ( <> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <Dialog {...dialog} aria-label="Welcome"> Welcome to Reakit! </Dialog> </> ); }
#Backdrop
You can use the DialogBackdrop
component to render a backdrop for the dialog.
import { Portal } from "reakit/Portal"; import { useDialogState, Dialog, DialogDisclosure, DialogBackdrop, } from "reakit/Dialog"; function Example() { const dialog = useDialogState(); return ( <> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <DialogBackdrop {...dialog}> <Dialog {...dialog} aria-label="Welcome"> Welcome to Reakit! </Dialog> </DialogBackdrop> </> ); }
#Initial focus
When opening Dialog
, focus is usually set on the first tabbable element within the dialog, including itself. So, if you want to set the initial focus on the dialog element, you can simply pass tabIndex={0}
to it. It'll be also included in the tab order.
import { Button } from "reakit/Button"; import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; function Example() { const dialog = useDialogState(); return ( <> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <Dialog {...dialog} tabIndex={0} aria-label="Welcome"> <Button onClick={dialog.hide}>Close</Button> </Dialog> </> ); }
Alternatively, you can define another element to get the initial focus with React hooks:
import React from "react"; import { Button } from "reakit/Button"; import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; function Example() { const dialog = useDialogState(); const ref = React.useRef(); React.useEffect(() => { if (dialog.visible) { ref.current.focus(); } }, [dialog.visible]); return ( <> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <Dialog {...dialog} aria-label="Welcome"> <Button>By default, initial focus would go here</Button> <br /> <br /> <Button ref={ref}>But now it goes here</Button> </Dialog> </> ); }
#Non-modal dialogs
There's still no consensus on how non-modal dialogs should behave. Some discussions like w3c/aria-practices#599 and this deleted section about non-modal dialogs indicate that it's pretty much a dialog that provides a keyboard mechanism to move focus outside it while leaving it open.
Reakit doesn't strictly follow that. When Dialog
has modal
set to false
:
- It doesn't render within a Portal.
- Focus is not trapped within the dialog.
- Body scroll isn't disabled.
There's a few use cases for these conditions, like Popover and Menu.
import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; function Example() { const dialog = useDialogState({ modal: false }); return ( <> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <Dialog {...dialog} aria-label="Welcome" style={{ position: "static", transform: "none" }} > Focus is not trapped within me. </Dialog> </> ); }
#Chat dialog
If desirable, a non-modal dialog can also be rendered within a Portal. The hideOnClickOutside
prop can be set to false
so clicking and focusing outside doesn't close it.
import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; import { Button } from "reakit/Button"; import { Portal } from "reakit/Portal"; function Example() { const dialog = useDialogState({ modal: false }); return ( <> <DialogDisclosure {...dialog}>Open chat</DialogDisclosure> <Portal> <Dialog {...dialog} aria-label="Welcome" hideOnClickOutside={false} style={{ transform: "none", top: "auto", left: "auto", bottom: 0, right: 16, width: 200, height: 300, }} > <Button onClick={dialog.hide}>Close chat</Button> </Dialog> </Portal> </> ); }
#Nested dialogs
Reakit supports multiple nested modal dialogs and non-modal dialogs. ESC closes only the currently focused one. If the closed dialog has other open dialogs within, they will all be closed.
import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; import { Button } from "reakit/Button"; function Example() { const dialog1 = useDialogState(); const dialog2 = useDialogState(); return ( <> <DialogDisclosure {...dialog1}>Open dialog</DialogDisclosure> <Dialog {...dialog1} aria-label="Test"> <p> Press <kbd>ESC</kbd> to close me. </p> <div style={{ display: "grid", gridGap: 16, gridAutoFlow: "column" }}> <Button onClick={dialog1.hide}>Close dialog</Button> <DialogDisclosure {...dialog2}>Open nested dialog</DialogDisclosure> </div> <Dialog {...dialog2} aria-label="Nested"> <Button onClick={dialog2.hide}>Close nested dialog</Button> </Dialog> </Dialog> </> ); }
#Alert dialogs
A dialog can be turned into an alert dialog by just setting its role
prop to alertdialog
. See WAI-ARIA Alert and Message Dialogs Pattern.
import { useDialogState, Dialog, DialogDisclosure } from "reakit/Dialog"; import { Button } from "reakit/Button"; function Example() { const dialog = useDialogState(); return ( <> <DialogDisclosure {...dialog}>Discard</DialogDisclosure> <Dialog {...dialog} role="alertdialog" aria-label="Confirm discard"> <p>Are you sure you want to discard it?</p> <div style={{ display: "grid", gridGap: 16, gridAutoFlow: "column" }}> <Button onClick={dialog.hide}>Cancel</Button> <Button onClick={() => { alert("Discarded"); dialog.hide(); }} > Discard </Button> </div> </Dialog> </> ); }
#Animating
Dialog
uses DisclosureContent underneath, so you can use the same approaches as described in the Animating section there.
import { css } from "emotion"; import { Button } from "reakit/Button"; import { useDialogState, Dialog, DialogBackdrop, DialogDisclosure, } from "reakit/Dialog"; const backdropStyles = css` perspective: 800px; transition: opacity 250ms ease-in-out; opacity: 0; &[data-enter] { opacity: 1; } `; const dialogStyles = css` transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; opacity: 0; transform-origin: top center; transform: translate3d(-50%, -10%, 0) rotateX(90deg); &[data-enter] { opacity: 1; transform: translate3d(-50%, 0, 0); } `; function Example() { const dialog = useDialogState({ animated: true }); return ( <div> <DialogDisclosure {...dialog}>Open dialog</DialogDisclosure> <DialogBackdrop {...dialog} className={backdropStyles}> <Dialog {...dialog} aria-label="Welcome" className={dialogStyles}> Welcome to Reakit! <br /> <br /> <Button onClick={dialog.hide}>Close</Button> </Dialog> </DialogBackdrop> </div> ); }
#Abstracting
You can build your own Dialog
component with a different API on top of Reakit.
import React from "react"; import { useDialogState, Dialog as BaseDialog, DialogDisclosure, } from "reakit/Dialog"; function Dialog({ disclosure, ...props }) { const dialog = useDialogState(); return ( <> <DialogDisclosure {...dialog} ref={disclosure.ref} {...disclosure.props}> {(disclosureProps) => React.cloneElement(disclosure, disclosureProps)} </DialogDisclosure> <BaseDialog {...dialog} {...props} /> </> ); } function Example() { return ( <Dialog disclosure={<button>Open custom dialog</button>}> My custom dialog </Dialog> ); }
#Accessibility
Dialog
has roledialog
.Dialog
hasaria-modal
set totrue
unless themodal
prop is set tofalse
.- When
Dialog
opens, focus moves to an element inside the dialog. - Focus is trapped within the modal
Dialog
. - ESC closes
Dialog
unlesshideOnEsc
is set tofalse
. - Clicking outside the
Dialog
closes it unlesshideOnClickOutside
is set tofalse
. - Focusing outside the non-modal
Dialog
closes it unlesshideOnClickOutside
is set tofalse
. - When
Dialog
closes, focus returns to its disclosure unless the closing action has been triggered by a click/focus on a tabbable element outside theDialog
. In this case,Dialog
closes and this element remains with focus. DialogDisclosure
extends the accessibility features of Disclosure.
Learn more in Accessibility.
#Composition
Dialog
uses DisclosureContent, and is used by Popover and its derivatives.DialogDisclosure
uses Disclosure, and is used by PopoverDisclosure and its derivatives.DialogBackdrop
uses DisclosureContent, and is used by PopoverBackdrop and its derivatives.
Learn more in Composition.
#Props
#useDialogState
-
baseId
string
ID that will serve as a base for all the items IDs.
-
visible
boolean
Whether it's visible or not.
-
animated
number | boolean
If
true
,animating
will be set totrue
whenvisible
is updated. It'll wait forstopAnimation
to be called or a CSS transition ends. Ifanimated
is set to anumber
,stopAnimation
will be called only after the same number of milliseconds have passed. -
modal
boolean
Toggles Dialog's
modal
state.- Non-modal:
preventBodyScroll
doesn't work and focus is free. - Modal:
preventBodyScroll
is automatically enabled, focus is trapped within the dialog and the dialog is rendered within aPortal
by default.
- Non-modal:
#Dialog
-
hideOnEsc
boolean | undefined
When enabled, user can hide the dialog by pressing
Escape
. -
hideOnClickOutside
boolean | undefined
When enabled, user can hide the dialog by clicking outside it.
-
preventBodyScroll
boolean | undefined
When enabled, user can't scroll on body when the dialog is visible. This option doesn't work if the dialog isn't modal.
-
unstable_initialFocusRef
RefObject<HTMLElement> | undefined
The element that will be focused when the dialog shows. When not set, the first tabbable element within the dialog will be used.
-
unstable_finalFocusRef
RefObject<HTMLElement> | undefined
The element that will be focused when the dialog hides. When not set, the disclosure component will be used.
-
unstable_orphan
boolean | undefined
Whether or not the dialog should be a child of its parent. Opening a nested orphan dialog will close its parent dialog if
hideOnClickOutside
is set totrue
on the parent. It will be set tofalse
ifmodal
isfalse
.
7 state props
These props are returned by the state hook. You can spread them into this component (
{...state}
) or pass them separately. You can also provide these props from your own state logic.
-
baseId
string
ID that will serve as a base for all the items IDs.
-
visible
boolean
Whether it's visible or not.
-
animated
number | boolean
If
true
,animating
will be set totrue
whenvisible
is updated. It'll wait forstopAnimation
to be called or a CSS transition ends. Ifanimated
is set to anumber
,stopAnimation
will be called only after the same number of milliseconds have passed. -
animating
boolean
Whether it's animating or not.
-
stopAnimation
() => void
Stops animation. It's called automatically if there's a CSS transition.
-
modal
boolean
Toggles Dialog's
modal
state.- Non-modal:
preventBodyScroll
doesn't work and focus is free. - Modal:
preventBodyScroll
is automatically enabled, focus is trapped within the dialog and the dialog is rendered within aPortal
by default.
- Non-modal:
-
hide
() => void
Changes the
visible
state tofalse
#DialogBackdrop
6 state props
These props are returned by the state hook. You can spread them into this component (
{...state}
) or pass them separately. You can also provide these props from your own state logic.
-
baseId
string
ID that will serve as a base for all the items IDs.
-
visible
boolean
Whether it's visible or not.
-
animated
number | boolean
If
true
,animating
will be set totrue
whenvisible
is updated. It'll wait forstopAnimation
to be called or a CSS transition ends. Ifanimated
is set to anumber
,stopAnimation
will be called only after the same number of milliseconds have passed. -
animating
boolean
Whether it's animating or not.
-
stopAnimation
() => void
Stops animation. It's called automatically if there's a CSS transition.
-
modal
boolean
Toggles Dialog's
modal
state.- Non-modal:
preventBodyScroll
doesn't work and focus is free. - Modal:
preventBodyScroll
is automatically enabled, focus is trapped within the dialog and the dialog is rendered within aPortal
by default.
- Non-modal:
#DialogDisclosure
-
disabled
boolean | undefined
Same as the HTML attribute.
-
focusable
boolean | undefined
When an element is
disabled
, it may still befocusable
. It works similarly toreadOnly
on form elements. In this case, onlyaria-disabled
will be set.
3 state props
These props are returned by the state hook. You can spread them into this component (
{...state}
) or pass them separately. You can also provide these props from your own state logic.
-
visible
boolean
Whether it's visible or not.
-
baseId
string
ID that will serve as a base for all the items IDs.
-
toggle
() => void
Toggles the
visible
state