Skip to content

Alert Dialog

A modal dialog that closes only through the answers it renders, for decisions that cannot be undone.

View source View as Markdown

Alert Dialog is Dialog with the ways out taken away. It is always modal, a press outside never closes it, and the only ways past it are the buttons it renders. Reach for it when the answer decides something that cannot be taken back, and use Dialog for everything else.

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"
  >
    <AlertDialog.Root>
      <AlertDialog.Trigger :class="danger">
        Delete project
      </AlertDialog.Trigger>

      <AlertDialog.Portal disabled>
        <AlertDialog.Backdrop :class="backdrop" />

        <AlertDialog.Popup :class="popup">
          <div class="flex flex-col gap-1">
            <AlertDialog.Title :class="title">
              Delete project?
            </AlertDialog.Title>
            <AlertDialog.Description :class="description">
              Every deployment, token and log goes with it. This cannot be
              undone.
            </AlertDialog.Description>
          </div>

          <div class="flex items-center justify-end gap-2">
            <AlertDialog.Close :class="quiet">Cancel</AlertDialog.Close>
            <AlertDialog.Close :class="danger">Delete</AlertDialog.Close>
          </div>
        </AlertDialog.Popup>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  </div>
</template>

<script setup lang="ts">
import { AlertDialog } from '@maas/mirror/vue'

const danger = [
  '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-danger-solid text-danger-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-danger-solid-hover active:bg-danger-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'
</script>

The example switches the portal off and positions the layer absolutely, so the alert dialog stays inside the preview frame rather than covering the viewport.

Usage guidelines

  • Give it a AlertDialog.Title that asks the question and a AlertDialog.Description that says what happens, so the reader has the consequence in front of them before they answer.
  • Render both answers. A dialog whose only button is the destructive one leaves the reader nothing safe to press.
  • Do not reach for it for anything reversible. A press outside is how people leave a layer, so take it away only where the next click destroys something.

Anatomy

The parts are Dialog’s, under their own names. AlertDialog.Portal is optional in the same way.

NameRequiredDescription
AlertDialog.Root true Renders its children. Owns the open state.
AlertDialog.TriggerRenders a <button> with aria-haspopup="dialog".
AlertDialog.PortalOptional. Renders a <div> in the body and teleports everything below it.
AlertDialog.BackdropRenders a <div> under the popup.
AlertDialog.Popup true Renders a <div role="alertdialog">. The focus scope and the dismissable layer live here.
AlertDialog.TitleRenders an <h2> and labels the popup.
AlertDialog.DescriptionRenders a <p> and describes the popup.
AlertDialog.CloseRenders a <button> that closes the alert dialog.
<script setup lang="ts">
import { AlertDialog } from '@maas/mirror/vue'
</script>

<template>
  <AlertDialog.Root>
    <AlertDialog.Trigger>Delete project</AlertDialog.Trigger>
    <AlertDialog.Portal>
      <AlertDialog.Backdrop />
      <AlertDialog.Popup>
        <AlertDialog.Title>Delete project?</AlertDialog.Title>
        <AlertDialog.Description>This cannot be undone.</AlertDialog.Description>
        <AlertDialog.Close>Cancel</AlertDialog.Close>
        <button @click="destroy">Delete</button>
      </AlertDialog.Popup>
    </AlertDialog.Portal>
  </AlertDialog.Root>
</template>

Examples

The options it overrules

modal is not an option here, and neither are the two outside dismissals. They are re-applied after the options are merged, so an object that names them is read and then overwritten.

resolveAlertDialogOptions({ modal: false })
// → { modal: true, dismiss: { escape: true, pointerDownOutside: false, focusOutside: false }, … }

Escape is the one way out that stays configurable, because a keyboard reader needs some way to leave the layer.

<template>
  <AlertDialog.Root :options="{ dismiss: { escape: false } }" />
</template>

Switch it off only where the surrounding flow gives the reader another way back.

Focusing the safe answer

Focus lands on the popup by default, so a screen reader announces the title before it reaches a button. Name the cancel button through focus.initial where the destructive answer sits closest to the pointer.

<script setup lang="ts">
const cancel = ref<HTMLElement | null>(null)
</script>

<template>
  <AlertDialog.Root :options="{ focus: { initial: () => cancel } }">
    <AlertDialog.Popup>
      <AlertDialog.Close ref="cancel">Cancel</AlertDialog.Close>
    </AlertDialog.Popup>
  </AlertDialog.Root>
</template>

Set it to false instead and focus stays where it was.

Asking from a dialog

An alert dialog opened from inside a dialog counts against it the same way a nested dialog does, so the dialog underneath carries data-has-nested-dialogs and Escape answers the alert first.

<template>
  <Dialog.Popup>
    <AlertDialog.Root>
      <AlertDialog.Trigger>Discard changes</AlertDialog.Trigger>
    </AlertDialog.Root>
  </Dialog.Popup>
</template>

Reaching it from anywhere

Give the root an id and useMirrorAlertDialog(id) opens, closes and reads that alert dialog from anywhere in the app.

<script setup lang="ts">
const { isOpen, open } = useMirrorAlertDialog(AlertDialogId.DeleteProject)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Delete project</Button>
</template>

An alert dialog and a dialog with the same id stay separate. The two keep their own stores, so useMirrorDialog('delete') and useMirrorAlertDialog('delete') reach different components.

API reference

Module. A bundled options object on the root, and useMirrorAlertDialog(id) as the programmatic API. Every part takes id to resolve an alert dialog it is not nested inside.

AlertDialog.Root

Renders its children and no element of its own.

Props

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

Options

OptionTypeDefault
disabled
booleanfalse
forceMount
booleanfalse
dismiss.escape
booleantrue
focus.trapped
booleantrue
focus.restore
booleantrue
focus.initial
AlertDialogFocusTargetnull
focus.final
AlertDialogFocusTargetnull
transition.popup
string | undefinedundefined
transition.backdrop
string | undefinedundefined

AlertDialogFocusTarget and AlertDialogChangeReason are Dialog’s types under their own names.

Emits

EmitPayload
update:open
boolean

Slot props: open, nested.

AlertDialog.Trigger

Renders a <button> with aria-haspopup="dialog", aria-expanded and aria-controls. Props and slot props are Dialog.Trigger’s.

AlertDialog.Portal

Renders a <div> inside a teleport. Props are Dialog.Portal’s.

AlertDialog.Backdrop

Renders a <div> with pointer-events: auto. Props are Dialog.Backdrop’s.

AlertDialog.Popup

Renders a <div role="alertdialog" aria-modal="true">, labelled by the title and described by the description. Props are Dialog.Popup’s.

AlertDialog.Title

Renders an <h2> and writes its ID into the popup’s aria-labelledby. No props beyond the primitive ones.

AlertDialog.Description

Renders a <p> and writes its ID into the popup’s aria-describedby. No props beyond the primitive ones.

AlertDialog.Close

Renders a <button> that closes the alert dialog. Props are Dialog.Close’s.

Composable

useMirrorAlertDialog(id) reaches an alert dialog from anywhere in the app, and returns what useMirrorDialog returns: isOpen, nestedCount, open, close and toggle.

Data attributes

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

CSS variables

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

Errors

Code
missing_alert_dialog_context
missing_instance_id
missing_element

Accessibility

The popup is a role="alertdialog" with aria-modal="true", labelled by AlertDialog.Title and described by AlertDialog.Description. Assistive technology announces the description along with the title, so put the consequence in the description rather than in the title.

KeyBehaviour
Enter / Space on the triggerOpens the alert dialog. On a non-native trigger, Space acts on keyup.
TabMoves between the answers. Focus never leaves the popup.
EscapeCloses the innermost open dialog and returns focus to its trigger, unless dismiss.escape is off.
Enter / Space on the close buttonCloses the alert dialog.