Dialog
Opens a layer over the page and keeps the reader in it until they are done.
Dialog puts a layer over the page and, while it is modal, takes the page out of
reach until the reader is done with it. Reach for it when a task needs its own
space and the page underneath can wait. When the answer cannot be postponed and
a stray click must not throw it away, use
AlertDialog.
<template>
<div
class="border-surface rounded-component-2xl relative isolate flex h-80 w-full max-w-md items-center justify-center overflow-hidden border-2"
>
<Dialog.Root>
<Dialog.Trigger :class="button">Edit profile</Dialog.Trigger>
<Dialog.Portal disabled>
<Dialog.Backdrop :class="backdrop" />
<Dialog.Popup :class="popup">
<div class="flex flex-col gap-1">
<Dialog.Title :class="title">Edit profile</Dialog.Title>
<Dialog.Description :class="description">
Change the name other people see.
</Dialog.Description>
</div>
<Field.Root
class="group flex flex-col gap-1.5"
:options="{ name: 'name' }"
>
<div :class="box">
<div :class="stack">
<span :class="line">
<Input
v-model="name"
:class="control"
placeholder=" "
autocomplete="off"
/>
</span>
<Label :class="label">Name</Label>
</div>
</div>
</Field.Root>
<div class="flex items-center justify-end gap-2">
<Dialog.Close :class="quiet">Cancel</Dialog.Close>
<Dialog.Close :class="button">Save</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, Field, Input, Label } from '@maas/mirror/vue'
const name = ref('Robin')
const button = [
'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
'rounded-component-lg border-2 border-[transparent] type-component-lg',
'bg-primary-solid text-primary-on-solid',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:bg-primary-solid-hover active:bg-primary-solid-active',
].join(' ')
const quiet = [
'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
'rounded-component-lg border-2 border-[transparent] type-component-lg',
'text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')
const backdrop = 'bg-surface-dimmer absolute inset-0'
const popup = [
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'flex w-[20rem] flex-col gap-5 p-6',
'rounded-component-2xl bg-primary-inverted shadow-component-high',
'outline-4 outline-transparent focus:outline-none',
'[&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
].join(' ')
const title = 'type-component-xl text-primary-solid'
const description = 'type-component-2xs text-primary-muted'
const box = [
'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
'rounded-component-lg border-surface border-2 px-[0.875rem]',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-within:focus-ring',
].join(' ')
const stack = [
'group/stack flex h-full max-h-full w-full flex-col-reverse',
'items-center justify-center gap-0 px-1',
'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')
const line = [
'relative flex h-0 w-full items-end',
'group-focus-within/stack:h-[0.9375rem]',
'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')
const control = [
'type-component-lg leading-[normal]! text-primary-solid',
'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
'placeholder:text-transparent',
].join(' ')
const label = [
'type-component-lg leading-[normal]! text-primary-muted',
'flex h-full w-full items-center [--mirror-label-cursor:text]',
"before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem]',
'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem]',
].join(' ')
</script>
The example switches the portal off and positions the layer absolutely, so the dialog stays inside the preview frame rather than covering the viewport.
Usage guidelines
- Give the dialog a
Dialog.Title. The popup labels itself from whichever title is mounted, and without one a screen reader announces an unnamed dialog. - Leave
modalalone unless the reader genuinely has to keep working on the page behind. A modal dialog traps focus, marks the rest of the documentinertand holds the page scroll, and those three go together. - The popup positions itself. There is no positioner part, because a dialog is anchored to the viewport rather than to its trigger.
- Focus lands on the popup rather than on the first control inside it, so the
title is read before a field is. Name a control through
focus.initialwhere the dialog is mostly a form.
Anatomy
Assemble the parts. Dialog.Portal is optional: omit it and the layer stays
where it is, positioned against the nearest containing block.
| Name | Required | Description |
|---|---|---|
| Dialog.Root | true | Renders its children. Owns the open state. |
| Dialog.Trigger | Renders a <button> with aria-haspopup="dialog". | |
| Dialog.Portal | Optional. Renders a <div> in the body and teleports everything below it. | |
| Dialog.Backdrop | Renders a <div> under the popup. | |
| Dialog.Popup | true | Renders a <div role="dialog">. The focus scope and the dismissable layer live here. |
| Dialog.Title | Renders an <h2> and labels the popup. | |
| Dialog.Description | Renders a <p> and describes the popup. | |
| Dialog.Close | Renders a <button> that closes the dialog. |
<script setup lang="ts">
import { Dialog } from '@maas/mirror/vue'
</script>
<template>
<Dialog.Root>
<Dialog.Trigger>Edit profile</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Change the name other people see.</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</template>
Examples
Centring the popup
The popup has no position of its own, so there are two ways to centre it. Give the popup a position of its own, which is the one to reach for first.
.popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
Or a wrapper inside the portal can do the centring, which is worth it once the popup is tall enough to need the page to scroll around it.
<template>
<Dialog.Portal class="fixed inset-0 grid place-items-center overflow-y-auto">
<Dialog.Backdrop class="fixed inset-0" />
<Dialog.Popup>…</Dialog.Popup>
</Dialog.Portal>
</template>
A modal dialog switches pointer events off everywhere but its own layer, so a
wrapper that is meant to take the press that dismisses the dialog needs
pointer-events: auto of its own. Dialog.Backdrop already ships with it.
Non-modal and focus-only
modal is three settings in one. Leave it at true and the dialog traps
focus, marks the rest of the document inert and locks the page scroll. Set it
to 'trap-focus' and only the trap remains, which is what you want when the
page behind has to keep scrolling. Set it to false and the page is left alone
entirely.
<template>
<Dialog.Root :options="{ modal: 'trap-focus' }" />
</template>
Dismissal
Escape closes the topmost dialog, and a press outside the popup closes it. Either can be switched off on its own.
<template>
<Dialog.Root
:options="{ dismiss: { escape: false, pointerDownOutside: false } }"
/>
</template>
dismiss.focusOutside is off by default. Focus cannot leave a modal dialog
anyway, and in a non-modal one a click into the page behind is already covered
by pointerDownOutside.
Focus
focus.initial and focus.final take an element, a getter for one, or false
to leave focus where it is. Both getters receive the reason the dialog opened
or closed, so a cancel can send focus somewhere else than a save.
<script setup lang="ts">
const nameField = ref<HTMLInputElement | null>(null)
</script>
<template>
<Dialog.Root :options="{ focus: { initial: () => nameField } }">
<Dialog.Popup>
<input ref="nameField" />
</Dialog.Popup>
</Dialog.Root>
</template>
Unset, focus lands on the popup on the way in and on the trigger on the way
out. Dialog.Popup takes initial-focus and final-focus as props too, for
the one dialog in a set that needs to differ.
Nested dialogs
A dialog opened from inside another one finds its parent through the component
tree, so the portal boundary between the two makes no difference. Escape closes
the innermost one first, the inner popup carries data-nested, and the outer
one carries data-has-nested-dialogs and a count.
.popup[data-has-nested-dialogs='true'] {
scale: calc(1 - var(--mirror-dialog-popup-nested-dialogs) * 0.05);
}
Only the outermost dialog holds the page scroll, so closing the inner one does not hand it back while the outer one is still open.
Reaching the dialog from anywhere
Give the root an id and useMirrorDialog(id) opens, closes and reads that
dialog from anywhere in the app, including from a component that renders none
of its parts.
<script setup lang="ts">
const { isOpen, open } = useMirrorDialog(DialogId.EditProfile)
</script>
<template>
<Button :aria-expanded="isOpen" @click="open()">Edit profile</Button>
</template>
Animating it
transition.popup and transition.backdrop name a Vue transition. Both are
unset by default, which leaves the popup to appear and disappear at once.
<template>
<Dialog.Root :options="{ transition: { popup: 'dialog', backdrop: 'fade' } }" />
</template>
.dialog-enter-active,
.dialog-leave-active {
transition: all 150ms ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
translate: 0 0.5rem;
}
Without a name the popup is still kept in the DOM until any CSS animation on it
finishes, which is the path to take when the popup is a consumer element under
as-child.
API reference
Module. A bundled options object on the root, and useMirrorDialog(id)
as the programmatic API. Every part takes id to resolve a dialog it is not
nested inside.
Dialog.Root
Renders its children and no element of its own.
Props
Options
DialogFocusTarget is boolean | HTMLElement | ((reason: DialogChangeReason) => HTMLElement | boolean | null) | null.
DialogChangeReason is one of trigger-press, outside-press, escape-key,
close-press, focus-out or imperative.
Emits
Slot props: open, nested.
Dialog.Trigger
Renders a <button> with aria-haspopup="dialog", aria-expanded and
aria-controls.
Props
Slot props: open, disabled.
Dialog.Portal
Renders a <div> inside a teleport, so the whole layer is one element in the
body.
Props
Slot props: open.
Dialog.Backdrop
Renders a <div> with pointer-events: auto.
Props
Slot props: open, nested.
Dialog.Popup
Renders a <div role="dialog">, labelled by the title and described by the
description. aria-modal is present while modal is true.
Props
Slot props: open, nested, hasNestedDialogs.
Dialog.Title
Renders an <h2> and writes its ID into the popup’s aria-labelledby. No
props beyond the primitive ones.
Dialog.Description
Renders a <p> and writes its ID into the popup’s aria-describedby. No props
beyond the primitive ones.
Dialog.Close
Renders a <button> that closes the dialog.
Props
Slot props: disabled.
Composable
useMirrorDialog(id) reaches a dialog from anywhere in the app.
Data attributes
CSS variables
Errors
Accessibility
The trigger is a <button> with aria-haspopup="dialog", aria-expanded and
aria-controls pointing at the popup, which keeps its identifier while it is
unmounted. The popup is a role="dialog", labelled by Dialog.Title and
described by Dialog.Description, and carries aria-modal while it is modal.