Dialog
A dialog is a modal window over the page. UniAria opens a native <dialog> with
showModal(), so the browser puts it in the top layer, makes the rest of the
page inert for the pointer, the keyboard and screen readers, and draws its
::backdrop. UniAria adds what the browser leaves out or does differently in
each engine: an open state you can control, Escape and backdrop dismissal, Tab
wrapping, page scroll lock, initial focus, focus restore and a closing phase
for exit animations.
It follows the WAI-ARIA Dialog (Modal)
and Alert Dialog
patterns. A dialog is always modal; for a non-modal one next to its trigger,
use a Popover with isNonModal. Overlays
covers what every overlay shares: the top layer, the layer stack and dismissal.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The dialog's look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
justify-items: start;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
.actions {
display: flex;
justify-content: end;
gap: 0.5rem;
}
</style><uni-dialog>
<button data-uni-part="dialog-trigger">Delete file…</button>
<dialog data-uni="dialog">
<h2 data-element="Title">Delete “report.pdf”?</h2>
<p data-element="Description">It stays in the trash for 30 days.</p>
<div class="actions">
<button data-element="Close">Cancel</button>
<uni-button id="delete">
<button data-uni="button" data-variant="danger">Delete</button>
</uni-button>
</div>
</dialog>
</uni-dialog>
<output id="log">Close it with Escape, the backdrop or a button.</output>
<script src="/demo/dialog.js"></script><script>const dialog = document.querySelector('uni-dialog');
const log = document.getElementById('log');
// Controlled, so the Delete action can close it.
dialog.isOpen = false;
dialog.addEventListener('openchange', (event) => {
dialog.isOpen = event.detail;
});
dialog.addEventListener('dismiss', (event) => {
log.textContent = `dismiss: ${event.detail.reason}`;
});
document.getElementById('delete').addEventListener('press', () => {
dialog.isOpen = false;
log.textContent = 'Deleted “report.pdf”.';
});
</script>React
import { dialog } from '@uniaria/core/dialog';
import { useBehavior } from '@uniaria/react';
export function DeleteFile(props: { onDelete(): void }) {
const confirm = useBehavior(dialog, {
onDismiss: (event) => {
if (event.returnValue === 'delete') props.onDelete();
},
});
return (
<>
<button {...confirm.parts.trigger}>Delete file…</button>
<dialog {...confirm.parts.root}>
<h2 {...confirm.parts.title}>Delete “report.pdf”?</h2>
<p {...confirm.parts.description}>It stays in the trash for 30 days.</p>
<form method="dialog">
<button {...confirm.parts.close}>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
</>
);
}Vue
<script setup lang="ts">
import { dialog } from '@uniaria/core/dialog';
import { useBehavior } from '@uniaria/vue';
const emit = defineEmits<{ delete: [] }>();
const confirm = useBehavior(dialog, {
onDismiss: (event) => {
if (event.returnValue === 'delete') emit('delete');
},
});
</script>
<template>
<button v-bind="confirm.parts.trigger">Delete file…</button>
<dialog v-bind="confirm.parts.root">
<h2 v-bind="confirm.parts.title">Delete “report.pdf”?</h2>
<p v-bind="confirm.parts.description">It stays in the trash for 30 days.</p>
<form method="dialog">
<button v-bind="confirm.parts.close">Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
</template>HTML
<uni-dialog id="delete-file">
<button data-uni-part="dialog-trigger">Delete file…</button>
<dialog data-uni="dialog">
<h2 data-element="Title">Delete “report.pdf”?</h2>
<p data-element="Description">It stays in the trash for 30 days.</p>
<form method="dialog">
<button data-element="Close">Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
</uni-dialog>
<script type="module">
import '@uniaria/elements/dialog';
document
.querySelector('#delete-file')
.addEventListener('dismiss', (event) => {
if (event.detail.returnValue === 'delete') console.log('Delete the file');
});
</script>The trigger and the <dialog> must both be inside <uni-dialog>. To open a
dialog from elsewhere, control it (see Controlled).
Render the <dialog> always: it stays hidden while closed, and opening it
renders nothing. Don’t put open on it, which opens a dialog non-modally;
use defaultOpen instead. Delete submits the form, which closes the dialog
with the button’s value (below); Cancel is the close part.
A <form method="dialog"> closes the dialog when it is submitted, as in HTML,
but through the dialog’s state: the dialog reports dismiss with reason
'submit' and the submitter’s value as returnValue, then closes with its
closing phase. Your own submit handlers run first; one that calls
preventDefault() keeps the dialog open, for example while validation fails.
Enter in a field submits the form as usual. Only a method="dialog"
submission (or a submitter with formmethod="dialog") closes the dialog.
React
export function RenameFile(props: {
name: string;
onRename(name: string): void;
}) {
const rename = useBehavior(dialog, {});
return (
<>
<button {...rename.parts.trigger}>Rename…</button>
<dialog {...rename.parts.root}>
<h2 {...rename.parts.title}>Rename file</h2>
<form
method="dialog"
onSubmit={(event) => {
const data = new FormData(event.currentTarget);
props.onRename(String(data.get('name')));
}}
>
<label>
Name{' '}
<input
name="name"
defaultValue={props.name}
required
data-autofocus
/>
</label>
<button {...rename.parts.close}>Cancel</button>
<button value="rename">Rename</button>
</form>
</dialog>
</>
);
}React doesn’t write autoFocus as an attribute (it focuses the element on
mount, while the dialog is still closed), so mark the field with
data-autofocus.
Vue
<script setup lang="ts">
import { dialog } from '@uniaria/core/dialog';
import { useBehavior } from '@uniaria/vue';
defineProps<{ name: string }>();
const emit = defineEmits<{ rename: [name: string] }>();
const rename = useBehavior(dialog);
function submit(event: SubmitEvent) {
const data = new FormData(event.currentTarget as HTMLFormElement);
emit('rename', String(data.get('name')));
}
</script>
<template>
<button v-bind="rename.parts.trigger">Rename…</button>
<dialog v-bind="rename.parts.root">
<h2 v-bind="rename.parts.title">Rename file</h2>
<form method="dialog" @submit="submit">
<label>
Name
<input name="name" :value.attr="name" required data-autofocus />
</label>
<button v-bind="rename.parts.close">Cancel</button>
<button value="rename">Rename</button>
</form>
</dialog>
</template>:value.attr sets the starting value, as React’s defaultValue does, so
what the user types stays. As in React, mark the field with
data-autofocus: the dialog focuses it when it opens.
HTML
<uni-dialog id="rename">
<button data-uni-part="dialog-trigger">Rename…</button>
<dialog data-uni="dialog">
<h2 data-element="Title">Rename file</h2>
<form method="dialog">
<label for="new-name">Name</label>
<input id="new-name" name="name" value="report.pdf" required autofocus />
<button data-element="Close">Cancel</button>
<button value="rename">Rename</button>
</form>
</dialog>
</uni-dialog>
<script type="module">
import '@uniaria/elements/dialog';
const rename = document.querySelector('#rename');
rename.addEventListener('dismiss', (event) => {
if (event.detail.returnValue === 'rename') {
console.log('Renamed to', rename.querySelector('input').value);
}
});
</script>The close part is a Button with type="button", so Cancel never submits.
For a message that needs an answer, set role to 'alertdialog' and turn
dismissal off, so only the buttons close it. Give it a description: screen
readers read it with the title when the dialog opens. Mark the least
destructive action to take focus.
React
const discard = useBehavior(dialog, {
role: 'alertdialog',
isDismissable: false,
onDismiss: (event) => {
if (event.returnValue === 'discard') props.onDiscard();
},
});
return (
<>
<button {...discard.parts.trigger}>Close editor</button>
<dialog {...discard.parts.root}>
<h2 {...discard.parts.title}>Discard changes?</h2>
<p {...discard.parts.description}>
Your edits to this page will be lost.
</p>
<form method="dialog">
<button {...discard.parts.close} data-autofocus>
Keep editing
</button>
<button value="discard">Discard</button>
</form>
</dialog>
</>
);Vue
<script setup lang="ts">
const emit = defineEmits<{ discard: [] }>();
const discard = useBehavior(dialog, {
role: 'alertdialog',
isDismissable: false,
onDismiss: (event) => {
if (event.returnValue === 'discard') emit('discard');
},
});
</script>
<template>
<button v-bind="discard.parts.trigger">Close editor</button>
<dialog v-bind="discard.parts.root">
<h2 v-bind="discard.parts.title">Discard changes?</h2>
<p v-bind="discard.parts.description">
Your edits to this page will be lost.
</p>
<form method="dialog">
<button v-bind="discard.parts.close" data-autofocus>Keep editing</button>
<button value="discard">Discard</button>
</form>
</dialog>
</template>HTML
<uni-dialog dismissable="false">
<button data-uni-part="dialog-trigger">Close editor</button>
<dialog data-uni="dialog" role="alertdialog">
<h2 data-element="Title">Discard changes?</h2>
<p data-element="Description">Your edits to this page will be lost.</p>
<form method="dialog">
<button data-element="Close" autofocus>Keep editing</button>
<button value="discard">Discard</button>
</form>
</dialog>
</uni-dialog>The element reads the role from the <dialog>: a role on <uni-dialog>
would give the host that role.
Pass isOpen and onOpenChange to open the dialog from anywhere: a menu item,
a keyboard shortcut, a route. Without a trigger part, focus returns to the
element focused before the dialog opened. A dismissal, the close part and a
dialog form only report the change; the dialog stays open until you set
isOpen to false.
React
const [isOpen, setOpen] = useState(false);
const settings = useBehavior(dialog, { isOpen, onOpenChange: setOpen });
return (
<>
<MenuItem onAction={() => setOpen(true)}>Settings…</MenuItem>
<dialog {...settings.parts.root}>…</dialog>
</>
);Vue
<script setup lang="ts">
const isOpen = ref(false);
const settings = useBehavior(dialog, () => ({
isOpen: isOpen.value,
onOpenChange: (open) => (isOpen.value = open),
}));
</script>
<template>
<MenuItem @action="isOpen = true">Settings…</MenuItem>
<dialog v-bind="settings.parts.root">…</dialog>
</template>HTML
const settings = document.querySelector('#settings');
// Setting the isOpen property makes the element controlled.
settings.isOpen = true;
settings.addEventListener('openchange', (event) => {
settings.isOpen = event.detail;
});The live demo above is controlled this way, so its Delete button can close the
dialog by setting isOpen.
The trigger is a Button, and it takes Button’s label, description, frame and extras as four parts of its own. All four sit outside the dialog, as the trigger does:
triggerLabelandtriggerDescriptiongo inside the trigger. While rendered, the label alone names the trigger and the description describes it, so an icon or a hint inside stays out of the name.triggerFramegoes around the trigger and its extras, andtriggerExtrasbeside the trigger, never inside it: your buttons, badges and counts. Both carry the trigger’s state attributes, and the pointer anywhere over the frame hovers the trigger.
React
import { button } from '@uniaria/core/button';
import { dialog } from '@uniaria/core/dialog';
import { useBehavior } from '@uniaria/react';
export function FruitPicker(props: { fruit: string; onClear(): void }) {
const picker = useBehavior(dialog, {});
const clear = useBehavior(button, { onPress: props.onClear });
return (
<>
<div {...picker.parts.triggerFrame}>
<button {...picker.parts.trigger}>
<span {...picker.parts.triggerLabel}>Fruit</span>
<span {...picker.parts.triggerDescription}>{props.fruit}</span>
</button>
<span {...picker.parts.triggerExtras}>
<button
{...clear.parts.root}
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden />
</button>
</span>
</div>
<dialog {...picker.parts.root}>
<h2 {...picker.parts.title}>Choose fruit</h2>…
</dialog>
</>
);
}Vue
<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { dialog } from '@uniaria/core/dialog';
import { useBehavior } from '@uniaria/vue';
defineProps<{ fruit: string }>();
const emit = defineEmits<{ clear: [] }>();
const picker = useBehavior(dialog);
const clear = useBehavior(button, { onPress: () => emit('clear') });
</script>
<template>
<div v-bind="picker.parts.triggerFrame">
<button v-bind="picker.parts.trigger">
<span v-bind="picker.parts.triggerLabel">Fruit</span>
<span v-bind="picker.parts.triggerDescription">{{ fruit }}</span>
</button>
<span v-bind="picker.parts.triggerExtras">
<button
v-bind="clear.parts.root"
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden="true" />
</button>
</span>
</div>
<dialog v-bind="picker.parts.root">
<h2 v-bind="picker.parts.title">Choose fruit</h2>
…
</dialog>
</template>HTML
<uni-dialog>
<div data-uni-part="dialog-trigger-frame">
<button data-uni-part="dialog-trigger">
<span data-uni-part="dialog-trigger-label">Fruit</span>
<span data-uni-part="dialog-trigger-description">Apple</span>
</button>
<span data-uni-part="dialog-trigger-extras">
<uni-button>
<button data-uni="button" data-variant="quiet" aria-label="Clear Fruit">
<svg aria-hidden="true">…</svg>
</button>
</uni-button>
</span>
</div>
<dialog data-uni="dialog">
<h2 data-element="Title">Choose fruit</h2>
…
</dialog>
</uni-dialog>
<script type="module">
import '@uniaria/elements/button';
import '@uniaria/elements/dialog';
</script>Each extra is a control of its own: name it in its context (“Clear Fruit”, not “Clear”), and it is a Tab stop after the trigger. While the dialog is open the page is inert, so the extras act only while it is closed. The dialog’s name still comes from its title, never from the trigger.
A dialog opened from inside another stacks above it. Escape and the backdrop
close only the top one, page scroll stays locked until the last one closes,
and closing the top one returns focus into the dialog below. Put the inner
<uni-dialog> (or the inner trigger) inside the outer <dialog>: everything
outside the top dialog is inert.
| Part | Element | Notes |
|---|---|---|
trigger |
button |
Optional. A press opens the dialog. It is a Button, with Button’s states. |
triggerLabel |
any, inside the trigger; usually span |
Optional. While rendered, it alone names the trigger. UniAria writes its id (unless it has one). |
triggerDescription |
any, inside the trigger; usually span |
Optional. Describes the trigger while rendered. UniAria writes its id (unless it has one). |
triggerFrame |
any; usually div |
Optional. Around the trigger and its extras. It carries the trigger’s state attributes, and the pointer over it hovers the trigger. |
triggerExtras |
any, inside the frame; usually span |
Optional. Beside the trigger, never inside it: your buttons, badges and counts. It carries the trigger’s state attributes. |
root |
dialog |
Required. Opened with showModal(), hidden while closed. UniAria writes its id (unless it has one), tabindex="-1", aria-labelledby, aria-describedby, and role for an alert dialog. Its content is yours. |
title |
any; usually a heading | Names the dialog through aria-labelledby. UniAria writes its id (unless it has one). |
description |
any | Optional. Linked through aria-describedby while rendered. Leave it out for long or structured content, which screen readers would read in one go. |
close |
button |
Optional. A press closes the dialog, even when it isn’t dismissable. A Button too. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
aria-labelledby and aria-describedby reference only the parts you render,
plus the ids in labelledBy and describedBy. Development builds warn when
the dialog has no name (no title, labelledBy or aria-label), when an alert
dialog has no description, and when the root isn’t a <dialog>. The custom
element has no role of its own and never takes focus.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
isOpen |
— (property: setting it makes it controlled) | boolean |
— | The open state, controlled. Pair it with onOpenChange. |
defaultOpen |
default-open |
boolean |
false |
The starting state, uncontrolled. With server rendering, it opens after hydration: showModal() needs a browser. |
isDismissable |
dismissable |
boolean |
true |
Escape and a press on the backdrop close it. dismissable="false" turns both off; the close part, a dialog form and your code still close it. |
isKeyboardDismissDisabled |
keyboard-dismiss-disabled |
boolean |
false |
Escape alone doesn’t close it; the backdrop still does. |
role |
— (read from the <dialog>’s role) |
'dialog' | 'alertdialog' |
'dialog' |
'alertdialog' for a message that needs an answer. |
isDisabled |
disabled |
boolean |
false |
The trigger can’t open it: Button’s isDisabled on the trigger. |
shouldRestoreFocus |
restore-focus |
boolean |
true |
Focus goes back when it closes (see Accessibility). restore-focus="false" turns it off. |
labelledBy |
labelled-by |
string |
— | More ids for aria-labelledby. |
describedBy |
described-by |
string |
— | More ids for aria-describedby. |
tooltip |
tooltip (the <uni-tooltip>’s id) |
a tooltip’s instance |
— | A Tooltip for the trigger. On the element, a <uni-tooltip> that <uni-dialog> sits in directly links too. With shouldOpenOnlyWhenTruncated, it measures triggerLabel and triggerDescription, else the trigger. |
| Event | Callback prop | Detail | When |
|---|---|---|---|
openchange |
onOpenChange |
boolean |
The trigger, a dismissal, the close part or a dialog form asks to open or close it. Never when you set isOpen. |
dismiss |
onDismiss |
DialogDismissEvent |
The user asked to close it, just before openchange with false. |
interface DialogDismissEvent {
reason: 'escape' | 'outside' | 'close' | 'submit' | 'browser';
returnValue: string; // the submitter's value for 'submit', else ''
}| Reason | The dialog closed because of |
|---|---|
escape |
Escape, or a close request without a key, such as Android’s back gesture. |
outside |
A press on the backdrop. |
close |
A press on the close part. |
submit |
A method="dialog" form submission. |
browser |
The browser closing it on its own: a close request Chromium won’t let a page cancel (without a recent user action), or the open <dialog> removed from the page. |
Setting isOpen yourself reports neither event. The custom element
dispatches both events on itself, bubbling, with the value in detail.
| Action | What it does |
|---|---|
open() |
Opens the dialog, as its trigger does, and moves focus inside. isDisabled only disables the trigger, so open() still works. |
close() |
Closes it after its exit animations and returns focus. Reports openChange, but no dismiss: no user asked. |
In React and Vue they are on the handle (confirm.actions.open()); on the
custom element they are methods
(document.querySelector('uni-dialog').open()). While isOpen is
controlled, each only reports openChange and leaves the change to you.
| Field | Type | Meaning |
|---|---|---|
isOpen |
boolean |
The dialog is open. |
isEntering |
boolean |
The first frames after opening, for enter transitions. |
isExiting |
boolean |
Closed, but still shown until its exit animations end. |
To skip rendering heavy content while the dialog is hidden, render the content, not the dialog, from state:
const isShown = useBehaviorState(
settings,
(state) => state.isOpen || state.isExiting,
);
return <dialog {...settings.parts.root}>{isShown && <SettingsForm />}</dialog>;On the custom element, read element.instance.getState(). See
State.
| Attribute | On | When |
|---|---|---|
data-uni="dialog" |
root | Always. |
data-uni-part="dialog-trigger" |
trigger | Always. |
data-uni-part="dialog-trigger-label", …-trigger-description, …-trigger-frame, …-trigger-extras |
the trigger’s label, description, frame and extras | Always. |
data-element |
every other part | Always. |
data-open |
every part but the trigger’s label and description | The dialog is open. |
data-entering |
every part but the trigger’s label and description | The first frames after opening. |
data-exiting |
every part but the trigger’s label and description | It is closing, until its exit animations end. |
data-hovered, data-pressed, data-focused, data-focus-visible, data-disabled |
trigger, its frame and extras, close | As on a Button. The frame and the extras carry the trigger’s, and data-hovered is set while the pointer is anywhere over the frame. |
The root matches :modal the whole time it is shown, closing phase included,
and its ::backdrop is the underlay.
To animate, style the hidden look on [data-entering] and [data-exiting]
(see Animating overlays). UniAria waits
for the animations and transitions running on the dialog and inside it,
::backdrop included, before it calls close(). Nothing waits for a fixed
time: a dialog with no running animation closes in the next frame, and opening
it again while it exits cancels the exit.
[data-uni='dialog'] {
transition:
opacity 150ms,
scale 150ms;
}
[data-uni='dialog']::backdrop {
background: rgb(0 0 0 / 0.4);
transition: opacity 150ms;
}
[data-uni='dialog']:is([data-entering], [data-exiting]) {
opacity: 0;
scale: 0.96;
}
[data-uni='dialog']:is([data-entering], [data-exiting])::backdrop {
opacity: 0;
}While the dialog is shown, UniAria locks page scroll with overflow: hidden on
<html>, plus scrollbar-gutter: stable when a classic scrollbar would
disappear (padding-right where that’s unsupported), so the page doesn’t
shift. Your own inline values come back when the last dialog closes. On iOS
it also adopts an overscroll-behavior: contain rule for every element,
inside an anonymous @layer so your CSS wins. The trigger and close parts get
Button’s touch-action.
The optional @uniaria/styles package styles
every part, as in the demo above, whose CSS tab holds only the page’s layout.
The dialog is a surface panel 360 pixels wide (22.5rem), narrowing with the
viewport and never taller than it, with its parts in a column 12 pixels apart
and 12 pixels in from its edge, and scrolling inside when its content is taller.
The title is an 18/24 heading, the description soft text, and the ::backdrop
dims the page. The trigger and the close part look like
Button, data-variant included. A trigger
with a triggerLabel is laid out as an item: its label and description take
one line each and end in an ellipsis, and in a frame the extras sit over the
trigger’s end (see
Labels, frames and extras).
Opening, the dialog fades in as it drops 24 pixels into place, with the
backdrop fading in, in 250 milliseconds; under reduced motion it shows and
hides at once.
| Key | Behavior |
|---|---|
| Enter, Space | On the trigger, opens the dialog. On the close part, closes it. |
| Escape | Closes the top dialog when it is dismissable and keyboard dismissal isn’t disabled. Otherwise nothing happens, and the browser doesn’t close it either. A widget inside that handles Escape and prevents its default keeps the dialog open. |
| Tab, Shift+Tab | Move through the dialog’s tabbable elements and wrap at both ends. Focus never reaches the page or the browser’s own controls. |
| Enter | In a field of a method="dialog" form, submits it, which closes the dialog. |
Each of the trigger’s extras is a Tab stop of its own after the trigger, in DOM order. The open dialog keeps Tab inside it, so the extras are reached once it closes.
- Focus on open. Focus goes to the first element marked
autofocusordata-autofocusthat can take it, else to the dialog itself, so a screen reader reads its name and description first and nothing is activated by surprise. Focus is visible when the dialog opened from the keyboard. - Focus on close, after the exit animation: the trigger, when there is
one and it can take focus; else the element focused before the dialog
opened; else the dialog below, for a nested one. To send focus elsewhere,
move it in the
<dialog>’scloseevent, which fires after that, or turn offshouldRestoreFocus. - The page behind is inert natively, in the browser and the accessibility
tree. UniAria sets no
aria-modal: a dialog opened withshowModal()is modal for screen readers already. - Spoken output. “button, Delete file…” on the trigger, then “dialog,
Delete “report.pdf”?, It stays in the trash for 30 days.” when it opens;
“alertdialog, …” for an alert dialog. With a
triggerLabel“Fruit” and atriggerDescription“Apple”, the trigger reads “button, Fruit, Apple”. - Extras aren’t part of the trigger’s name or description: each reads on
its own, such as “button, Clear Fruit”. Text that should describe the
trigger, such as a badge, goes in
triggerDescription. - No popup ARIA on the trigger. UniAria sets no
aria-haspopup,aria-expandedoraria-controls: the open dialog makes the trigger inert, so “expanded” would never be heard. - Live announcements. UniAria announces what nothing on screen shows, like
a Field’s new error or a NumberField’s
stepped value, and your own
announce()calls from@uniaria/core/dom, through a hidden live region next to your app. Chromium drops inert live regions from the accessibility tree, so while a modal dialog is open the region moves into the top one, as its last child, and goes back to<body>when the last dialog closes. It is visually hidden and absolutely positioned; mind it in:last-childselectors inside the dialog. - Overlays inside. A Popover or Tooltip
used in a dialog must be inside the
<dialog>element, or the dialog makes it inert. Development builds warn when one opens outside. - A name is required: a title part,
labelledBy, or anaria-labelon the<dialog>.
- The native
<dialog>. React Aria, Zag and Cube UI Kit render adivorsectionin a portal; React Aria makes the page inert itself, and Cube UI Kit leaves it exposed. Here the browser makes the page inert, and::backdropis the underlay. - Dismissable by default, as in Cube UI Kit. React Aria defaults
isDismissabletofalseand applies it to outside presses only; here it turns off Escape too. - Initial focus goes to the dialog unless you mark an element. The APG and
native
showModal()focus the first focusable element, which in a confirmation may be the destructive button. - Focus returns to the trigger after the exit animation. Natively, WebKit often returns it to the body.
- One definition with optional trigger and close parts, instead of DialogTrigger and Dialog. Without a trigger, it is React Spectrum’s DialogContainer.
- A
dismissevent with a reason, as Cube UI Kit’sonDismiss(action), and dialog forms that go through the state, so a controlled dialog can refuse to close.