Alert Dialog
A modal dialog that closes only through the answers it renders, for decisions that cannot be undone.
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.
<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.Titlethat asks the question and aAlertDialog.Descriptionthat 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.
| Name | Required | Description |
|---|---|---|
| AlertDialog.Root | true | Renders its children. Owns the open state. |
| AlertDialog.Trigger | Renders a <button> with aria-haspopup="dialog". | |
| AlertDialog.Portal | Optional. Renders a <div> in the body and teleports everything below it. | |
| AlertDialog.Backdrop | Renders a <div> under the popup. | |
| AlertDialog.Popup | true | Renders a <div role="alertdialog">. The focus scope and the dismissable layer live here. |
| AlertDialog.Title | Renders an <h2> and labels the popup. | |
| AlertDialog.Description | Renders a <p> and describes the popup. | |
| AlertDialog.Close | Renders 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
Options
AlertDialogFocusTarget and AlertDialogChangeReason are Dialog’s types
under their own names.
Emits
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
CSS variables
Errors
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.