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.Teleport disabled>
        <Dialog.Backdrop :class="backdrop" />

        <Dialog.Content :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>
                <Field.Label :class="label">Name</Field.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.Content>
      </Dialog.Teleport>
    </Dialog.Root>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Dialog } from '@maas/mirror/vue/Dialog'
import { Field } from '@maas/mirror/vue/Field'
import { Input } from '@maas/mirror/vue/Input'

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 teleport 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.must
  • 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.should
  • The popup positions itself. There is no positioner part, because a dialog is anchored to the viewport rather than to its trigger.may
  • 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 the popup’s initialFocus prop where the dialog is mostly a form.may