Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

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.

A confirmation dialog
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>

Usage

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.

Forms in dialogs

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.

Alert dialogs

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.

Controlled

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’s label and extras

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:

  • triggerLabel and triggerDescription go 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.
  • triggerFrame goes around the trigger and its extras, and triggerExtras beside 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.

Nested dialogs

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.

Anatomy

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.

Props

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.

Events

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.

Actions

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.

State

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.

Styling

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.

Default styles

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.

Keyboard

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.

Accessibility

  • Focus on open. Focus goes to the first element marked autofocus or data-autofocus that 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>’s close event, which fires after that, or turn off shouldRestoreFocus.
  • The page behind is inert natively, in the browser and the accessibility tree. UniAria sets no aria-modal: a dialog opened with showModal() 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 a triggerDescription “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-expanded or aria-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-child selectors 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 an aria-label on the <dialog>.

Compared with other libraries

  • The native <dialog>. React Aria, Zag and Cube UI Kit render a div or section in a portal; React Aria makes the page inert itself, and Cube UI Kit leaves it exposed. Here the browser makes the page inert, and ::backdrop is the underlay.
  • Dismissable by default, as in Cube UI Kit. React Aria defaults isDismissable to false and 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 dismiss event with a reason, as Cube UI Kit’s onDismiss(action), and dialog forms that go through the state, so a controlled dialog can refuse to close.