Dialog (Modal)
A fully-managed, renderless dialog component jam-packed with accessibility and keyboard features, perfect for building completely custom modal and dialog windows for your next application.
To get started, install Headless UI via npm:
npm install @headlessui/react
Dialogs are built using the Dialog
, Dialog.Panel
, Dialog.Title
and Dialog.Description
components.
When the dialog's open
prop is true
, the contents of the dialog will render. Focus will be
moved inside the dialog and trapped there as the user cycles through the focusable elements. Scroll
is locked, the rest of your application UI is hidden from screen readers, and clicking outside the
Dialog.Panel
or pressing the Escape key will fire the close
event and close the
dialog.
import { useState } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( <Dialog open={isOpen} onClose={() => setIsOpen(false)}> <Dialog.Panel> <Dialog.Title>Deactivate account</Dialog.Title> <Dialog.Description> This will permanently deactivate your account </Dialog.Description> <p> Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone. </p> <button onClick={() => setIsOpen(false)}>Deactivate</button> <button onClick={() => setIsOpen(false)}>Cancel</button> </Dialog.Panel> </Dialog> ) }
If your dialog has a title and description, use the Dialog.Title
and Dialog.Description
components to provide the most accessible experience. This will link your title and description
to the root dialog component via the aria-labelledby
and aria-describedby
attributes, ensuring
their contents are announced to users using screenreaders when your dialog opens.
Dialogs have no automatic management of their open/closed state. To show and hide your dialog, pass
React state into the open
prop. When open
is true the dialog will render, and when it's false
the dialog will unmount.
The onClose
callback fires when an open dialog is dismissed, which happens when the user clicks
outside the your Dialog.Panel
or presses the Escape key. You can use this callback to
set open
back to false and close your dialog.
import { useState } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { // The open/closed state lives outside of the Dialog and is managed by you
let [isOpen, setIsOpen] = useState(true)function handleDeactivate() { // ... } return ( /* Pass `isOpen` to the `open` prop, and use `onClose` to set the state back to `false` when the user clicks outside of the dialog or presses the escape key. */<Dialog open={isOpen} onClose={() => setIsOpen(false)}><Dialog.Panel> <Dialog.Title>Deactivate account</Dialog.Title> <Dialog.Description> This will permanently deactivate your account </Dialog.Description> <p> Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone. </p> {/* You can render additional buttons to dismiss your dialog by setting `isOpen` to `false`. */} <button onClick={() => setIsOpen(false)}>Cancel</button> <button onClick={handleDeactivate}>Deactivate</button></Dialog.Panel></Dialog> ) }
Style the Dialog
and Dialog.Panel
components using the className
or style
props like you
would with any other element. You can also introduce additional elements if needed to achieve a
particular design.
import { useState } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( <Dialog open={isOpen} onClose={() => setIsOpen(false)} className="relative z-50" > <div className="fixed inset-0 flex w-screen items-center justify-center p-4"> <Dialog.Panel className="w-full max-w-sm rounded bg-white"> <Dialog.Title>Complete your order</Dialog.Title> {/* ... */} </Dialog.Panel> </div> </Dialog> ) }
Clicking outside the Dialog.Panel
component will close the dialog, so keep that in mind when
deciding which styles to apply to which elements.
If you'd like to add an overlay or backdrop behind your Dialog.Panel
to bring attention to the
panel itself, we recommend using a dedicated element just for the backdrop and making it a sibling
to your panel container:
import { useState } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( <Dialog open={isOpen} onClose={() => setIsOpen(false)} className="relative z-50" > {/* The backdrop, rendered as a fixed sibling to the panel container */}
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />{/* Full-screen container to center the panel */} <div className="fixed inset-0 flex w-screen items-center justify-center p-4"> {/* The actual dialog panel */} <Dialog.Panel className="mx-auto max-w-sm rounded bg-white"> <Dialog.Title>Complete your order</Dialog.Title> {/* ... */} </Dialog.Panel> </div> </Dialog> ) }
This lets you transition the backdrop and panel independently with their own animations, and rendering it as a sibling ensures that it doesn't interfere with your ability to scroll long dialogs.
Making a dialog scrollable is handled entirely in CSS, and the specific implementation depends on the design you are trying to achieve.
Here's an example where the entire panel container is scrollable, and the panel itself moves as you scroll:
import { useState } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( <Dialog open={isOpen} onClose={() => setIsOpen(false)} className="relative z-50" > {/* The backdrop, rendered as a fixed sibling to the panel container */} <div className="fixed inset-0 bg-black/30" aria-hidden="true" /> {/* Full-screen scrollable container */}
<div className="fixed inset-0 w-screen overflow-y-auto">{/* Container to center the panel */}<div className="flex min-h-full items-center justify-center p-4">{/* The actual dialog panel */} <Dialog.Panel className="mx-auto max-w-sm rounded bg-white"> <Dialog.Title>Complete your order</Dialog.Title> {/* ... */} </Dialog.Panel> </div> </div> </Dialog> ) }
When creating a scrollable dialog with a backdrop, make sure the backdrop is rendered behind the scrollable container, otherwise the scroll wheel won't work when hovering over the backdrop, and the backdrop may obscure the scrollbar and prevent users from clicking it with their mouse.
For accessibility reasons, your dialog should contain at least one focusable element. By default,
the Dialog
component will focus the first focusable element (by DOM order) once it is rendered,
and pressing the Tab key will cycle through all additional focusable elements within the contents.
Focus is trapped within the dialog as long as it is rendered, so tabbing to the end will start cycling back through the beginning again. All other application elements outside of the dialog will be marked as inert and thus not focusable.
If you'd like something other than the first focusable element to receive initial focus when your
dialog is initially rendered, you can use the initialFocus
ref:
import { useState, useRef } from 'react' import { Dialog } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true)
let completeButtonRef = useRef(null)function completeOrder() { // ... } return ( /* Use `initialFocus` to force initial focus to a specific ref. */ <DialoginitialFocus={completeButtonRef}open={isOpen} onClose={() => setIsOpen(false)} > <Dialog.Panel> <Dialog.Title>Complete your order</Dialog.Title> <p>Your order is all ready!</p> <button onClick={() => setIsOpen(false)}>Cancel</button><button ref={completeButtonRef} onClick={completeOrder}>Complete order </button> </Dialog.Panel> </Dialog> ) }
If you've ever implemented a Dialog before, you've probably come across Portals in React. Portals let you invoke components from one place in the DOM (for instance deep within your application UI), but actually render to another place in the DOM entirely.
Since Dialogs and their backdrops take up the full page, you typically want to render them as a sibling to the root-most node of your React application. That way you can rely on natural DOM ordering to ensure that their content is rendered on top of your existing application UI. This also makes it easy to apply scroll locking to the rest of your application, as well as ensure that your Dialog's contents and backdrop are unobstructed to receive focus and click events.
Because of these accessibility concerns, Headless UI's Dialog
component actually uses a Portal
under-the-hood. This way we can provide features like unobstructed event handling and making the
rest of your application inert. So, when using our Dialog, there's no need to use a Portal yourself!
We've already taken care of it.
To animate the opening/closing of the dialog, use the Transition component. All
you need to do is wrap the Dialog
in a <Transition>
, and dialog will transition automatically
based on the state of the show
prop on the <Transition>
.
When using <Transition>
with your dialogs, you can remove the open
prop, as the dialog will read
the show
state from the <Transition>
automatically.
import { useState, Fragment } from 'react' import { Dialog, Transition } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return (
<Transitionshow={isOpen}enter="transition duration-100 ease-out"enterFrom="transform scale-95 opacity-0"enterTo="transform scale-100 opacity-100"leave="transition duration-75 ease-out"leaveFrom="transform scale-100 opacity-100"leaveTo="transform scale-95 opacity-0"as={Fragment}> <Dialog onClose={() => setIsOpen(false)}> <Dialog.Panel> <Dialog.Title>Deactivate account</Dialog.Title> {/* ... */} </Dialog.Panel> </Dialog> </Transition> ) }
To animate your backdrop and panel separately, wrap your Dialog
in Transition
and wrap your
backdrop and panel each with their own Transition.Child
:
import { useState, Fragment } from 'react' import { Dialog, Transition } from '@headlessui/react' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( // Use the `Transition` component at the root level
<Transition show={isOpen} as={Fragment}><Dialog onClose={() => setIsOpen(false)}> {/* Use one Transition.Child to apply one transition to the backdrop... */}<Transition.Childas={Fragment}enter="ease-out duration-300"enterFrom="opacity-0"enterTo="opacity-100"leave="ease-in duration-200"leaveFrom="opacity-100"leaveTo="opacity-0"><div className="fixed inset-0 bg-black/30" /> </Transition.Child> {/* ...and another Transition.Child to apply a separate transition to the contents. */}<Transition.Childas={Fragment}enter="ease-out duration-300"enterFrom="opacity-0 scale-95"enterTo="opacity-100 scale-100"leave="ease-in duration-200"leaveFrom="opacity-100 scale-100"leaveTo="opacity-0 scale-95"><Dialog.Panel> <Dialog.Title>Deactivate account</Dialog.Title> {/* ... */} </Dialog.Panel> </Transition.Child> </Dialog> </Transition> ) }
If you want to animate your dialogs using another animation library like Framer
Motion or React Spring and need
more control, you can use the static
prop to tell Headless UI not to manage rendering itself, and
control it manually with another tool:
import { useState } from 'react' import { Dialog } from '@headlessui/react' import { AnimatePresence, motion } from 'framer-motion' function MyDialog() { let [isOpen, setIsOpen] = useState(true) return ( // Use the `Transition` component + show prop to add transitions.
<AnimatePresence>{open && (<Dialogstaticas={motion.div}open={isOpen}onClose={() => setIsOpen(false)} > <div className="fixed inset-0 bg-black/30" /> <Dialog.Panel> <Dialog.Title>Deactivate account</Dialog.Title> {/* ... */} </Dialog.Panel> </Dialog> )}</AnimatePresence>) }
The open
prop is still used for manage scroll locking and focus trapping, but as long as static
is present, the actual element will always be rendered regardless of the open
value, which allows
you to control it yourself externally.
When the Dialog's open
prop is true
, the contents of the Dialog will render and focus will be
moved inside the Dialog and trapped there. The first focusable element according to DOM order will
receive focus, although you can use the initialFocus
ref to control which element receives
initial focus. Pressing Tab on an open Dialog cycles through all the focusable elements.
When a Dialog
is rendered, clicking outside of the Dialog.Panel
will close the Dialog
.
No mouse interaction to open the Dialog
is included out-of-the-box, though typically you will
wire a <button />
element up with an onClick
handler that toggles the Dialog's open
prop to
true
.
Command | Description |
Esc | Closes any open Dialogs |
Tab | Cycles through an open Dialog's contents |
Shift + Tab | Cycles backwards through an open Dialog's contents |
When a Dialog is open, scroll is locked and the rest of your application UI is hidden from screen readers.
All relevant ARIA attributes are automatically managed.
The main Dialog component.
Prop | Default | Description |
open | — | Boolean Whether the |
onClose | — | (false) => void Called when the |
initialFocus | — | React.MutableRefObject A ref to an element that should receive focus first. |
as | div | String | Component The element or component the |
static | false | Boolean Whether the element should ignore the internally managed open/closed state. |
unmount | true | Boolean Whether the element should be unmounted or hidden based on the open/closed state. |
Render Prop | Description |
open |
Whether or not the dialog is open. |
This indicates the panel of your actual Dialog. Clicking outside of this component will trigger the
onClose
of the Dialog
component.
Prop | Default | Description |
as | div | String | Component The element or component the |
Render Prop | Description |
open |
Whether or not the dialog is open. |
This is the title for your Dialog. When this is used, it will set the aria-labelledby
on the
Dialog.
Prop | Default | Description |
as | h2 | String | Component The element or component the |
Render Prop | Description |
open |
Whether or not the dialog is open. |
This is the description for your Dialog. When this is used, it will set the aria-describedby
on
the Dialog.
Prop | Default | Description |
as | p | String | Component The element or component the |
Render Prop | Description |
open |
Whether or not the dialog is open. |
As of Headless UI v1.6, Dialog.Overlay
is deprecated, see the release notes for migration instructions.
Prop | Default | Description |
as | div | String | Component The element or component the |
Render Prop | Description |
open |
Whether or not the dialog is open. |
If you're interested in predesigned component examples using Headless UI and Tailwind CSS, check out Tailwind UI — a collection of beautifully designed and expertly crafted components built by us.
It's a great way to support our work on open-source projects like this and makes it possible for us to improve them and keep them well-maintained.