Skip to content

Dialog

Opens a layer over the page and keeps the reader in it until they are done.

View source View as Markdown

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.

App.vue
<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 modal alone unless the reader genuinely has to keep working on the page behind. A modal dialog traps focus, marks the rest of the document inert and 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.initial where 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.

NameRequiredDescription
Dialog.Root true Renders its children. Owns the open state.
Dialog.TriggerRenders a <button> with aria-haspopup="dialog".
Dialog.PortalOptional. Renders a <div> in the body and teleports everything below it.
Dialog.BackdropRenders a <div> under the popup.
Dialog.Popup true Renders a <div role="dialog">. The focus scope and the dismissable layer live here.
Dialog.TitleRenders an <h2> and labels the popup.
Dialog.DescriptionRenders a <p> and describes the popup.
Dialog.CloseRenders 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

PropTypeDefault
id
stringgenerated
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
DialogOptionssee below

Options

OptionTypeDefault
modal
boolean | 'trap-focus'true
disabled
booleanfalse
forceMount
booleanfalse
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleanfalse
focus.trapped
booleantrue
focus.restore
booleantrue
focus.initial
DialogFocusTargetnull
focus.final
DialogFocusTargetnull
transition.popup
string | undefinedundefined
transition.backdrop
string | undefinedundefined

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

EmitPayload
update:open
boolean

Slot props: open, nested.

Dialog.Trigger

Renders a <button> with aria-haspopup="dialog", aria-expanded and aria-controls.

Props

PropTypeDefault
disabled
boolean | undefinedoptions.disabled

Slot props: open, disabled.

Dialog.Portal

Renders a <div> inside a teleport, so the whole layer is one element in the body.

Props

PropTypeDefault
to
string | RendererElementbody
disabled
booleanfalse
defer
booleanfalse

Slot props: open.

Dialog.Backdrop

Renders a <div> with pointer-events: auto.

Props

PropTypeDefault
forceMount
boolean | undefinedoptions.forceMount

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

PropTypeDefault
forceMount
boolean | undefinedoptions.forceMount
initialFocus
DialogFocusTargetoptions.focus.initial
finalFocus
DialogFocusTargetoptions.focus.final

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

PropTypeDefault
disabled
booleanfalse

Slot props: disabled.

Composable

useMirrorDialog(id) reaches a dialog from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
nestedCount
ComputedRef<number>
open
(reason?: DialogChangeReason) => void
close
(reason?: DialogChangeReason) => void
toggle
(reason?: DialogChangeReason) => void

Data attributes

PartAttributeValue
every part
data-dialog
the instance ID
Dialog.Portal, Dialog.Backdrop, Dialog.Popup
data-state
open | closed
Dialog.Trigger
data-popup-open
true
Dialog.Trigger, Dialog.Close
data-disabled
true
Dialog.Backdrop, Dialog.Popup
data-nested
true
Dialog.Popup
data-has-nested-dialogs
true
Dialog.Portal
data-dialog-layer
true

CSS variables

VariableDefault
--mirror-dialog-trigger-cursor
pointer
--mirror-dialog-trigger-disabled-cursor
not-allowed
--mirror-dialog-close-cursor
pointer
--mirror-dialog-close-disabled-cursor
not-allowed
--mirror-dialog-popup-nested-dialogs
0

Errors

Code
missing_dialog_context
missing_instance_id
missing_element

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.

KeyBehaviour
Enter / Space on the triggerOpens the dialog. On a non-native trigger, Space acts on keyup.
TabMoves between the controls in the popup. While the dialog traps focus, it never leaves.
EscapeCloses the innermost open dialog and returns focus to its trigger.
Enter / Space on the close buttonCloses the dialog.