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 or yarn.

Please note that this library only supports Vue 3.

# npm npm install @headlessui/vue # Yarn yarn add @headlessui/vue

Dialogs are built using the Dialog, DialogOverlay, DialogTitle and DialogDescription 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 or pressing the Escape key will fire the close event and close the dialog.

<template> <Dialog :open="isOpen" @close="setIsOpen"> <DialogOverlay /> <DialogTitle>Deactivate account</DialogTitle> <DialogDescription> This will permanently deactivate your account </DialogDescription> <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 @click="setIsOpen(false)">Deactivate</button> <button @click="setIsOpen(false)">Cancel</button> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

Dialogs have no automatic management of their open/closed state. To show and hide your dialog, pass a ref into the open prop. When open is true the dialog will render, and when it's false the dialog will unmount.

The close event fires when an open dialog is dismissed, which happens when the user clicks outside the contents of your dialog or presses the Escape key. You can use this event to set open back to false and close your dialog.

<template> <!-- Pass the `isOpen` ref to the `open` prop, and use the `close` event to set the ref back to `false` when the user clicks outside of the dialog or presses the escape key. -->
<Dialog :open="isOpen" @close="setIsOpen">
<DialogOverlay /> <DialogTitle>Deactivate account</DialogTitle> <DialogDescription> This will permanently deactivate your account </DialogDescription> <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 your `isOpen` state to `false`. -->
<button @click="setIsOpen(false)">Cancel</button>
<button @click="handleDeactivate">Deactivate</button> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() { // The open/closed state lives outside of the Dialog and // is managed by you.
let isOpen = ref(true);
return { isOpen, setIsOpen(value) { isOpen.value = value; }, handleDeactivate() { // ... } }; }, };
</script>

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 its 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 its 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:

<template>
<Dialog :initialFocus="completeButtonRef" :open="isOpen" @close="setIsOpen">
<DialogOverlay /> <DialogTitle>Complete your order</DialogTitle> <p>Your order is all ready!</p> <button @click="setIsOpen(false)">Deactivate</button> <!-- Use `initialFocus` to force initial focus to a specific ref. -->
<button ref="completeButtonRef" @click="completeOrder">
Complete order </button> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() {
let completeButtonRef = ref(null);
let isOpen = ref(true); return {
completeButtonRef,
isOpen, setIsOpen(value) { isOpen.value = value; }, completeOrder() { // ... }, }; }, };
</script>

Typically modal dialogs will be rendered on top of a transparent dark background. You can style the DialogOverlay component to achieve this look.

The overlay component accepts normal Vue props like class, so you can style it using any technique you like. Be sure to place it before the rest of your dialog's contents in the DOM so it doesn't obscure your content's interactive elements.

<template> <Dialog :open="isOpen" @close="setIsOpen" class="fixed inset-0 z-10 overflow-y-auto" > <!-- Use the overlay to style a dim backdrop for your dialog -->
<DialogOverlay class="fixed inset-0 bg-black opacity-30" />
<!-- ... --> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

There's nothing special about the contents of your dialog – you can use whatever HTML and CSS you please. Typical modals will have a max width and be centered in the screen, as in the example below, but fullscreen treatments on smaller screens are also common.

<template> <Dialog :open="isOpen" @close="setIsOpen" class="fixed inset-0 z-10 overflow-y-auto" > <div class="flex items-center justify-center min-h-screen"> <DialogOverlay class="fixed inset-0 bg-black opacity-30" /> <div class="relative max-w-sm mx-auto bg-white rounded"> <DialogTitle>Complete your order</DialogTitle> <!-- ... --> </div> </div> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

For accessibility reasons, you should use the DialogTitle and DialogDescription components when rendering content that labels and describes your dialog contents. They will be automatically linked to the root Dialog component via the aria-labelledby and aria-describedby attributes, and their contents will be announced to users using screenreaders.

<template> <Dialog :open="isOpen" @close="setIsOpen"> <DialogOverlay /> <!-- Use the Title and Description components when appropriate to improve the accessibility of your custom modals. -->
<DialogTitle>Deactivate account</DialogTitle>
<DialogDescription>
This will permanently deactivate your account
</DialogDescription>
<!-- ... --> </Dialog> </template> <script> import { ref } from "vue"; import { Dialog, DialogOverlay, DialogTitle, DialogDescription, } from "@headlessui/vue"; export default { components: { Dialog, DialogOverlay, DialogTitle, DialogDescription }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

If you've ever implemented a Dialog before, you've probably come across the concept of Portals. 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 overlays take up the full page, you typically want to render them as a sibling to the root-most node of your 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 overlay 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 your dialog, use the provided TransitionRoot component from Headless UI (rather than Vue's build-in <transition> component). You can then add the isOpen prop to the TransitionRoot's show prop, and safely remove the open prop from the Dialog component.

<template> <!-- Use the TransitionRoot component to add transitions. Use the appear prop to animate your dialog on initial render. -->
<TransitionRoot
appear
:show="isOpen"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<!-- Add `@close` to your Dialog. --> <Dialog @close="setIsOpen"> <DialogOverlay /> <DialogTitle>Deactivate account</DialogTitle> <!-- ... --> <button @click="isOpen = false">Close</button> </Dialog> </TransitionRoot> </template> <script> import { ref } from "vue"; import { TransitionRoot, Dialog, DialogOverlay, DialogTitle } from "@headlessui/vue"; export default { components: { TransitionRoot, Dialog, DialogOverlay, DialogTitle }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

To animate the Dialog's overlay and contents separately, use TransitionRoot and TransitionChild:

<template> <!-- Use the TransitionRoot component to add transitions. Use the appear prop to animate your dialog on initial render. -->
<TransitionRoot appear as="template">
<!-- Add `@close` to your Dialog. -->
<Dialog @close="setIsOpen">
<!-- Use one TransitionChild to apply one transition to the overlay... -->
<TransitionChild
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay /> </TransitionChild> <!-- ...and another TransitionChild to apply a separate transition to the contents. -->
<TransitionChild
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogTitle>Deactivate account</DialogTitle> <!-- ... --> </TransitionChild> </Dialog> </TransitionRoot> </template> <script> import { ref } from "vue"; import { TransitionRoot, TransitionChild, Dialog, DialogOverlay, DialogTitle, } from "@headlessui/vue"; export default { components: { TransitionRoot, TransitionChild, Dialog, DialogOverlay, DialogTitle, }, setup() { let isOpen = ref(true); return { isOpen, setIsOpen(value) { isOpen.value = value; }, }; }, }; </script>

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 the DialogOverlay 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 click handler that toggles the Dialog's open prop to true.

CommandDescription

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.

PropDefaultDescription
open
Boolean

Whether the Dialog is open or not.

initialFocus
HTMLElement

A ref to an element that should receive focus first.

asdiv
String | Component

The element or component the Dialog should render as.

staticfalse
Boolean

Whether the element should ignore the internally managed open/closed state.

unmounttrue
Boolean

Whether the element should be unmounted or hidden based on the open/closed state.

EventDescription
close

Emitted when the Dialog is dismissed (via the overlay or Escape key). Typically used to close the dialog by setting open to false.

Slot PropDescription
open

Boolean

Whether or not the dialog is open.

This can be used to create an overlay for your Dialog component. Clicking on the overlay will close the Dialog.

PropDefaultDescription
asdiv
String | Component

The element or component the DialogOverlay should render as.

Slot PropDescription
open

Boolean

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.

PropDefaultDescription
ash2
String | Component

The element or component the DialogTitle should render as.

Slot PropDescription
open

Boolean

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.

PropDefaultDescription
asp
String | Component

The element or component the DialogDescription should render as.

Slot PropDescription
open

Boolean

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.