Components

Dialog

How to build a modal dialog that takes focus and gives it back, that everyone can close, that developers control and that agents can check.

Dialogs ask for someone's full attention, like confirming a delete, editing a profile or signing in, while the page waits behind them. So much of what a dialog does happens out of sight, in focus and in the accessibility tree, which is why it's so easy to get wrong. Think of a dialog that keyboard users can tab straight out of into the page behind it, one that a screen reader announces as nothing at all, or one that closes and throws focus back to the top of the page.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how a dialog looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to open them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Dialog with the same API in each, apart from two names in Angular, which Type-safe explains. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Dialog is modal: while it's open, nothing else on the page can be used. Showing more next to what opened it, without blocking the page, is what a Popover is for.

Edit profile

Your name and email address, as other people see them.

<Button onClick={() => setOpen(true)}>Edit profile</Button>

<Dialog
  id="edit-profile"
  title="Edit profile"
  description="Your name and email address, as other people see them."
  open={open}
  onClose={() => setOpen(false)}
  actions={
    <>
      <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
      <Button color="primary" onClick={() => setOpen(false)}>Save changes</Button>
    </>
  }
>
  {/* …the form's fields */}
</Dialog>

At a glance

AspectDialog
Element<dialog>, opened with showModal()
Roledialog, or alertdialog for a confirmation that needs an answer
NameIts title, linked with aria-labelledby, and its description, if it has one, with aria-describedby
KeyboardTab and Shift+Tab stay inside it, and Escape asks to close it
FocusMoves inside when it opens, and back to what opened it when it closes
StatesOpen and closed, while the rest of the page is inert
Sizessm, md and lg, which set how wide it gets
PatternWAI-ARIA APG Dialog (Modal) and Alert Dialog
WCAG 2.21.3.1, 1.4.3, 1.4.10, 2.1.1, 2.1.2, 2.4.3, 2.4.6 and 4.1.2, among others

User Interface (UI)

Let's start with how a dialog looks: its parts, how wide it gets, what goes in it, how it shows that it's open and how to tune it.

Anatomy

  1. Backdrop — The ::backdrop behind the dialog, which dims the page while it's out of reach.
  2. Panel — The <dialog> itself, which carries the size and holds every other part.
  3. Title — An <h2> that says what the dialog is for. It names the dialog.
  4. Close button — A Button, named "Close", that asks to close the dialog. Alert dialogs leave it out.
  5. Description — Optional. A sentence or two that screen readers read along with the title.
  6. Body — Optional. The content, like a message or a form, which scrolls when it doesn't fit.
  7. Actions — Optional. The Buttons that answer the dialog, like Cancel and Save.

Every dialog has a panel and a title. The title, the close button and the description sit in a header at the top, and the actions in a row at the bottom, so people always know what they're answering and how. The parts always render in the same order, which you can see in the DOM contract.

Sizes

The size prop sets how wide the dialog gets, at most, and md is the default. We recommend picking the smallest size that fits the content, since a short question in a wide panel leaves people reading lines that run across the screen.

A sm dialog

It gets as wide as its size allows, and never wider than the screen, less a margin.

A md dialog

It gets as wide as its size allows, and never wider than the screen, less a margin.

A lg dialog

It gets as wide as its size allows, and never wider than the screen, less a margin.

<Dialog id="share" size="sm" title="Share project" open={open} onClose={() => setOpen(false)}>
  …
</Dialog>
{/* …md, lg */}
SizeMax. widthGood for
sm384pxConfirmations, with a sentence or two and their actions
md512pxShort forms and messages, like renaming a file
lg768pxLonger forms and content, like settings or a list to pick from

Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. Whatever its size, the dialog is never wider or taller than the screen, less 16px on each side, so on a phone 320px wide it fills the screen, with the page's edge still showing around it (WCAG 1.4.10). When its content is taller than that, the body scrolls, while the title and the actions stay in view.

Title, content and actions

A dialog's content goes in three places: a title and an optional description at the top, the body in the middle and the actions at the bottom.

Edit profile

Your name and email address, as other people see them.

<Dialog
  id="edit-profile"
  title="Edit profile"
  description="Your name and email address, as other people see them."
  open={open}
  onClose={() => setOpen(false)}
  actions={
    <>
      <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
      <Button color="primary" onClick={save}>Save changes</Button>
    </>
  }
>
  <label htmlFor="name">Full name</label>
  <Input id="name" autoComplete="name" />
  <label htmlFor="email">Email address</label>
  <Input id="email" type="email" autoComplete="email" />
</Dialog>
  • The title says what the dialog is for, in a few words, like "Edit profile" or "Delete project?". It's also the dialog's name, so it's what screen readers announce as focus moves in, and what voice control users and agents find it by.
  • The description is for a sentence or two that people need before they answer, like what a delete will take with it. Screen readers read it right after the title. Anything longer, like a form, a list or a table, goes in the body instead, which isn't part of the description, since screen readers would read it all out in one go, without its structure.
  • The actions are Buttons, named by what they do. We recommend putting the one that leaves things as they are first, like Cancel, and the main one last, at the end of the row, so people read them, and Tab reaches them, in that order. Give the main action color="primary", or color="error" when it destroys something, and keep to one of them per dialog.
  • The close button is an icon-only, ghost Button named "Close", which closeLabel translates. It sits at the top, beside the title, where people look for it.

States

The Dialog has two states of its own, open and closed, and the page around it has one too, since it's inert while the dialog is open.

StateSelectorRecommended look
Closed:not([open])Hidden, by the browser's own styles
Open[open], and :modal once showModal() ranThe panel in the middle of the screen, over the backdrop, which dims the page
Opening@starting-styleA fade and a grow from 96% over 150ms, only when people haven't asked for less motion

Hover, focus and disabled belong to the controls inside a dialog, like its Buttons and Inputs, which show them the same way they do everywhere else. You'll find how each state behaves under Every state.

Tokens

The panel looks just like a Card, with the same padding, spacing, corners, border and type, so the two read as one family. Only its background differs, since it has to cover the page: it's Canvas, the background browsers give a native dialog, which follows color-scheme, so it fits your light and dark themes as long as they set it.

Its text reads one theme token, --color--neutral-text, like the Card's, and the backdrop is black at 50%, which dims a light page and a dark one alike. The Buttons inside read their own tokens, so copy tokens.css from the Button into your theme.

With our reference tokens, the title and the content come to 14.9:1 on white, and the description, at 70% of the text colour, to 5.6:1. The panel has a 1px border at 20% of the text colour, which also sets it apart on a dark page, where the backdrop barely shows. If you change the tokens or the background, make sure to check the same pairs.

If you need to tune a dialog, you can set its own variables, such as --dialog--width, --dialog--padding or --dialog--backdrop--background-color, from a class. The Dialog's styles sit in the components cascade layer, so your class wins without any specificity tricks:

Settings.tsx
import styles from "./Settings.module.css";

export function Settings({ open, onClose }: { open: boolean; onClose: () => void }) {
  return (
    <Dialog id="settings" title="Settings" className={styles.wide} open={open} onClose={onClose}>
      …
    </Dialog>
  );
}
Settings.module.css
.wide {
  --dialog--width: 60rem;
}

User Experience (UX)

Now let's look at how a dialog behaves for every person, input and setting. A good dialog is accessible, predictable, designed in every state and adaptive.

Accessible

Use the native element

A native <dialog>, opened with showModal(), gives you most of what a modal dialog needs for free. It goes in the top layer, above everything else, without a z-index. The rest of the page becomes inert, so people can't focus it, click it or read it with a screen reader. Escape asks to close it, it has the dialog role, and its ::backdrop covers the page.

A <div> with role="dialog" and aria-modal="true" only gives you the role, so every one of those is one more thing to rebuild by hand. On top of that, aria-modal alone doesn't stop screen readers from reading the page behind it in every browser.

{/* Do: you get the top layer, an inert page, Escape and the role for free */}
<dialog ref={dialog} aria-labelledby="edit-profile-title">…</dialog>
<button type="button" onClick={() => dialog.current?.showModal()}>Edit profile</button>

{/* Don't: there's no top layer, inert page or Escape until you rebuild them */}
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="edit-profile-title">…</div>

Two things to keep in mind. Open a modal dialog with showModal(), never with show() or the open attribute, which both open it as a non-modal dialog that leaves the page behind it working. And don't add tabindex to the <dialog> itself, since it moves focus to a control inside it, not to the panel.

Give every dialog a title

When focus moves into a dialog, screen readers announce its role, its name and its description, like "Edit profile, dialog, Your name and email address, as other people see them." The name comes from the title, which the Dialog links with aria-labelledby, so give every dialog a visible title that says what it's for (WCAG 1.3.1, 2.4.6, 4.1.2). A dialog without one is announced as just "dialog", and people have to read through it to find out why it opened.

The title is an <h2>, so screen reader users can also jump to it by heading. It's one level below the page's <h1>, which stays the page's own, whatever the dialog is about.

Keep people inside

While the dialog is open, the page behind it is inert. Tab and Shift+Tab move between the dialog's own controls, and past the last one, on to the browser's own interface, like the address bar, but never to the page (WCAG 2.4.3). Screen readers can't read the page either, so their virtual cursor stays in the dialog, and a click outside it lands on the backdrop. That's the whole point of a modal dialog, and showModal() does it for you.

It isn't a keyboard trap, though, since Escape and the close button always lead out (WCAG 2.1.2).

Contrast and size

  • The title, the description and the content need a contrast of at least 4.5:1 against the panel (WCAG 1.4.3).
  • The panel's border doesn't need 3:1, since the panel isn't a control and the backdrop already sets it apart. It's there for dark pages, where the backdrop barely shows.
  • The close button is a sm Button, 32 × 32px, above the 24 × 24px minimum (WCAG 2.5.8), and the actions are md Buttons, as everywhere else.

Predictable

Move focus in, and back out

When a dialog opens, focus moves inside it, so keyboard and screen reader users start right where the dialog asks for their attention (WCAG 2.4.3). The Dialog moves it, in this order, to:

  1. The control you mark with data-autofocus.
  2. Or else, the first control in its body, like the first input of a form.
  3. Or else, its first action, which in an alert dialog is the one that leaves things as they are.
  4. Or else, its close button.

The close button comes last, even though it comes first on the page, since starting there would mean tabbing past it to do anything. Mark a control with data-autofocus when the first one isn't where people should start, like the email address in a sign-in dialog that opens with a "Continue with Google" button:

<Dialog id="sign-in" title="Sign in" open={open} onClose={() => setOpen(false)}>
  <Button variant="outline">Continue with Google</Button>
  <label htmlFor="email">Email address</label>
  {/* Most people sign in with their email address, so it starts there */}
  <Input id="email" type="email" autoComplete="email" data-autofocus />
</Dialog>

We use data-autofocus rather than autofocus, since it works the same in every framework, while React focuses an autoFocus element as it renders, before the dialog is open. Browsers honour autofocus in a dialog too, so the Dialog counts it as well.

When the dialog closes, focus goes back to the control that opened it, so people carry on where they left off. Sometimes that control is gone, like a Delete button on a row the dialog just deleted. Focus then has nowhere to go, and falls back to the top of the page, so move it yourself, to the next row or the list, as the Button's Move focus where people expect it explains.

Keyboard

KeyDialog
Tab, Shift+TabMove between its controls, and never to the page behind it
EscapeAsks to close it, in an alert dialog too
Enter, SpacePress the focused Button, and Enter submits a form inside it

Escape belongs to the control that has focus first. A control inside a dialog that closes on Escape too, like a menu, closes itself and stops it there, so one press doesn't close both.

Close it every way people expect

Every way a dialog closes fires close, with a reason that says which:

ReasonWhen
escapePeople press Escape, or make another close request, like the back gesture on Android
close-buttonPeople press the close button
backdropPeople press the backdrop, outside the panel, which an alert dialog ignores
actionPeople send a <form method="dialog"> inside it, with the value of its button
closedThe browser has already closed it, which you can't turn down

The Dialog asks, rather than closing itself. It stays open until you set open to false, which you'll usually do as soon as close fires. That way, the dialog on the screen and the state in your code stay in step, and you can say no, like when people would lose what they've typed. A dialog with unsaved changes should ask before it closes, rather than throw them away, so in that case, open an alert dialog that asks whether to discard them, and keep the first one open until people answer.

There's one case where you can't say no. A page can only turn down a close request, like the back gesture on Android, if people have pressed or typed something since the last one, so a second back gesture in a row closes the dialog. The Dialog then fires close with the reason closed, since the dialog is already gone, and gives focus back. Set open to false for closed, even in a handler that keeps the dialog open for every other reason, or open and the screen will disagree.

A press on the backdrop only counts when it starts there too. People often select text in a dialog and let go of the button outside it, and that shouldn't close it.

Every state

Open

While a dialog is open, it's the only thing on the page people can use. The page behind it is inert, and it doesn't scroll either. Scrolling the page under a dialog would move what people came back to, so the Dialog stops it, and keeps the space its scrollbar took, so the page doesn't shift sideways when it goes.

Keep to one dialog at a time. A dialog that opens another one, like the "Discard changes?" above, is fine, as long as it's on top and closes first, but a stack of them is hard to follow, and harder still with a screen reader.

Closed

A closed dialog is hidden, so it isn't in the accessibility tree, and screen readers, agents and Tab can't find anything in it. The Dialog stays in the page while it's closed, so a form inside keeps what people typed, if they close it and open it again. If you'd rather it started fresh each time, reset the form when close fires.

Alert dialogs

An alert dialog asks a question that needs an answer before people go on, like whether to delete a project. Set role="alertdialog", so screen readers announce it as an alert, and read its description straight away.

Delete project?

This deletes the project and its 12 files for everyone. You can't undo it.

<Button color="error" variant="outline" onClick={() => setOpen(true)}>Delete project</Button>
<Dialog
  id="delete-project"
  role="alertdialog"
  size="sm"
  title="Delete project?"
  description="This deletes the project and its 12 files for everyone. You can't undo it."
  open={open}
  onClose={() => setOpen(false)}
  actions={
    <>
      <Button variant="outline" onClick={() => setOpen(false)}>Keep project</Button>
      <Button color="error" onClick={deleteProject}>Delete project</Button>
    </>
  }
/>

An alert dialog works a little differently, since a stray click shouldn't count as an answer:

  • It has no close button, and a press on the backdrop doesn't close it. Escape still does, as the APG asks, which is why it needs an action that leaves things as they are, like "Keep project".
  • Focus starts on that action, the least destructive one, so a quick Enter never deletes anything. Mark it with data-autofocus, or, when the body has no controls, put it first, since the Dialog then starts on the first action.
  • Name both actions by what they do, like "Keep project" and "Delete project", rather than "Cancel" and "OK", so people know what they're agreeing to without reading the question again.

Keep alert dialogs for what can't be undone. For everyday actions, like archiving a message, it's kinder to act straight away and offer undo, as the Button's Make destructive actions recoverable explains, since people soon learn to press through a confirmation without reading it.

Adaptive

Pointer, touch and pen

A press on the backdrop asks to close a dialog, but only when it starts and ends there. That way, selecting text in the dialog and letting go outside it doesn't close it, and neither does a scroll gesture that starts on the panel.

Small screens

On a narrow screen, the dialog fills the width, less 16px on each side, and on a short one, the height, with the body scrolling between the title and the actions. On phones, the on-screen keyboard covers the bottom of the screen too, so check that a focused input in a dialog stays in view above it.

Motion

The dialog fades in and grows from 96% as it opens, over 150ms. It only does when people haven't asked for less motion (prefers-reduced-motion: no-preference), and opens straight away otherwise (WCAG 2.3.3). It closes straight away either way, since people have already moved on.

More contrast

When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the panel's border is drawn in the text colour at full strength, rather than at 20%.

Forced colours

Windows contrast themes replace every colour with the user's own. The panel's background and text become the theme's own, and its border stays, in the theme's text colour, which is what sets the panel apart from the page.

Colour scheme

The panel's background is Canvas, and its text reads tokens that use light-dark(), and both follow color-scheme, so the Dialog adapts to your light and dark themes on its own, as long as they set it.

Text size and spacing

Sizes are in rem, so the panel grows with its text, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A long title wraps rather than getting cut off, and the body scrolls when there's no more room.

Languages and direction

The Dialog uses logical properties, so the close button moves to the left in right-to-left languages, and the actions start from the right. Translate the close button's label with closeLabel, like closeLabel="إغلاق" in Arabic, since screen readers read it and voice control users say it.

Developer Experience (DX)

Next, let's look at how developers use the Dialog. It has one API, with the same names as every other component, it's typed end to end, and it leaves when to open and close it to you.

import { useState } from "react";
import { Button } from "@/components/Button";
import { Dialog } from "@/components/Dialog";
import { Input } from "@/components/Input";

export function EditProfile() {
  const [open, setOpen] = useState(false);

  function save() {
    // …save the profile, then close the dialog.
    setOpen(false);
  }

  return (
    <>
      <Button onClick={() => setOpen(true)}>Edit profile</Button>
      <Dialog
        id="edit-profile"
        title="Edit profile"
        open={open}
        onClose={() => setOpen(false)}
        actions={
          <>
            <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
            <Button color="primary" onClick={save}>Save changes</Button>
          </>
        }
      >
        <label htmlFor="name">Full name</label>
        <Input id="name" autoComplete="name" />
      </Dialog>
    </>
  );
}

Vanilla and Astro have no state to bind open to, so their script opens the dialog from the button that controls it, and lets the browser close it. The close event's returnValue then says why.

Consistent

Once you've learned the Button's API, you already know most of the Dialog's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
sizePropHow big the component is, which for a dialog is how wide it gets
openPropWhether it's showing, for anything that opens on top of the page
actionsSlotButtons that act on the component, after everything else
closeEventPeople asked to close it, and why

We recommend size="md" and role="dialog" as the defaults. The Dialog also leaves a few things out, on purpose:

  • There's no variant or color. A dialog has no intent of its own, so its actions say what it's about, like a Delete project Button with color="error".
  • There's no trigger slot. The button that opens a dialog is a Button of yours, with its own label and its own place on the page, and the Dialog gives focus back to it on its own.
  • There's no modal prop. A dialog that leaves the page working, next to what opened it, is a Popover.
  • There's no closable prop either. Every dialog closes on Escape, and an alert dialog leaves out the close button and ignores the backdrop on its own.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like size="xl", which isn't one of the Dialog's sizes, or a dialog without a title.

PropTypeDefaultDescription
openbooleanfalseShows the dialog. It never changes on its own
idstringRequired. Its title's and description's IDs start with it
titlestringRequired. The heading that names the dialog
descriptionstringA sentence or two that screen readers read along with the title
role"dialog" | "alertdialog""dialog"alertdialog for a confirmation that needs an answer
size"sm" | "md" | "lg""md"How wide it gets, at most
closeLabelstring"Close"The close button's label, for other languages
EventPayloadDescription
close"escape" | "close-button" | "backdrop" | "action" | "closed", stringFires when people ask to close it, with why, and for action, the value of the button that sent the <form method="dialog">. Fires with closed when it's already closed

Angular is the one framework where two of these names change, since its Dialog sits on the <dialog> itself. A title attribute stays on the element even when an input reads it, which would show the title as a tooltip over the whole panel, so the title is dialogTitle. close is already the <dialog>'s own event, and an output carries a single value, so the event is closeRequest, with { reason, value }.

SlotDescription
defaultThe content, like a message or a form, which scrolls when it doesn't fit
actionsThe Buttons that answer the dialog, like Cancel and Save, in the order people read them

We also export DialogProps, which comes in handy for confirmations you set up from data:

import type { DialogProps } from "@/components/Dialog";

const confirmations: Record<string, Pick<DialogProps, "id" | "title" | "description">> = {
  delete: { id: "delete-project", title: "Delete project?", description: "You can't undo it." },
  archive: { id: "archive-project", title: "Archive project?", description: "Nobody can change it after." },
};

Composable

The Dialog renders the <dialog> as its root, so everything you pass lands on it, including classes, styles, data attributes and event listeners. Its title and description get their IDs from the dialog's own id, as in edit-profile-title, so you can point at them from elsewhere, like a test.

Forms

A <form method="dialog"> is how HTML answers a dialog. On its own, it closes the dialog and sets returnValue to the value of the button that sent it. The Dialog asks instead, firing close with the reason action and that value, so you decide what happens, the same way as for every other reason. That also means the answer works before your JavaScript loads, in Astro and Vanilla.

<Dialog
  id="delete-project"
  role="alertdialog"
  title="Delete project?"
  open={open}
  onClose={(reason, value) => {
    setOpen(false);
    if (value === "delete") deleteProject();
  }}
  actions={
    <form method="dialog">
      <Button type="submit" value="keep" variant="outline">Keep project</Button>
      <Button type="submit" value="delete" color="error">Delete project</Button>
    </form>
  }
/>

A form in the body works like any other, so a dialog that edits something can send it with a submit Button among its actions, through the button's form attribute, and close once it's saved.

With other components

  • A Button opens the dialog, and gets focus back when it closes. The close button is a Button too, and so are the actions, with their names, focus rings and type="button".
  • An Input in the body gets the focus when the dialog opens, if it's the first control there, and keeps its label and error message as it would anywhere else.
  • A Spinner shows that an action is under way, inside its Button, with loading, which keeps the focus on the Button while the dialog waits.
  • A Popover or a Tooltip inside a dialog opens in the top layer too, above the dialog, and closes on Escape before it does, and so will the menus on our Roadmap.

Controllable

The Dialog doesn't keep any state you can't reach. Whether it's open is yours, and so is everything that happens when people ask to close it:

StateHow you control it
Openopen, which it never changes on its own
Closingclose, with why, after which you set open to false, or keep it open, unless it's closed
Initial focusdata-autofocus on the control to start on
Focus afterBack to the opener on its own, or anywhere you move it once open is false
Rolerole, which also decides the close button and the backdrop

Agentic Experience (AX)

Finally, let's look at how agents read, open and check a dialog. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how they see the Edit profile dialog once it's open, leaving out its text:

- dialog "Edit profile":
  - heading "Edit profile" [level=2]
  - button "Close"
  - textbox "Full name": Ada Lovelace
  - textbox "Email address": ada@example.com
  - button "Cancel"
  - button "Save changes"

While it's open, the page behind it is inert, so it's left out of the tree, and an agent only sees the dialog, as a screen reader user would. While it's closed, it's the other way round. With a role and a name, an agent can find the dialog and act inside it:

const dialog = page.getByRole("dialog", { name: "Edit profile" });
await dialog.getByRole("textbox", { name: "Full name" }).fill("Ada Lovelace");
await dialog.getByRole("button", { name: "Save changes" }).click();

Described

Here's the whole contract in one block, for agents to read before they build or review a dialog:

component: Dialog
summary: Asks for someone's full attention, on top of the page, until they answer or close it. Its title names it.
standard: https://opencomponents.dev/docs/components/dialog
element: dialog # opened with showModal(), which puts it in the top layer and makes the page inert
role: dialog # alertdialog, with role="alertdialog"
props:
  open: { type: boolean, default: false } # it never changes on its own
  id: { type: string } # required. Its title's and description's IDs start with it
  title: { type: string } # required
  description: { type: string }
  role: { type: [dialog, alertdialog], default: dialog }
  size: { type: [sm, md, lg], default: md }
  closeLabel: { type: string, default: Close }
slots:
  default: The content, like a message or a form, which scrolls when it doesn't fit.
  actions: The Buttons that answer the dialog, like Cancel and Save, in the order people read them.
events:
  close: "escape, close-button, backdrop or action, and the value of the button that sent a <form method=dialog>. Fires when people ask to close it, which it leaves to you. closed when it's already closed, like when the browser closed it, so open has to follow."
states:
  open: "[open], and :modal once showModal() ran"
  closed: ":not([open]), hidden by the browser's own styles"
keyboard:
  Tab: Moves focus between its controls, and never to the page behind it.
  Escape: Fires close, with the reason escape, in an alert dialog too.
parts: [header, title, close, description, body, actions] # data-slot, in this order. Only the header and the title are always there
tokens:
  theme: ["--color--neutral-text"]
  variables: [--dialog--width, --dialog--padding, --dialog--gap, --dialog--radius, --dialog--border-color, --dialog--background, --dialog--backdrop--background-color]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/dialog#checklist

It's also published on its own at /raw/docs/components/dialog.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like dialog/return-focus, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a dialog with a form and two actions:

<Dialog id="edit-profile" title="Edit profile" :open="open" @close="open = false">
  <label for="name">Full name</label>
  <Input id="name" autocomplete="name" />
  <template #actions>
    <Button variant="outline" @click="open = false">Cancel</Button>
    <Button color="primary" @click="save">Save changes</Button>
  </template>
</Dialog>

And here's what it renders:

<dialog class="dialog" id="edit-profile" aria-labelledby="edit-profile-title" data-size="md">
  <div data-slot="header">
    <h2 id="edit-profile-title" data-slot="title">Edit profile</h2>
    <button class="button" type="button" data-variant="ghost" data-color="neutral" data-size="sm" data-icon-only data-slot="close">
      <span data-slot="leading" aria-hidden="true"><svg>…</svg></span>
      <span data-slot="label">Close</span>
    </button>
  </div>
  <div data-slot="body">
    <label for="name">Full name</label>
    <div class="input" data-size="md">
      <input data-slot="control" type="text" id="name" autocomplete="name" />
    </div>
  </div>
  <div data-slot="actions">
    <button class="button" type="button" data-variant="outline" data-color="neutral" data-size="md">
      <span data-slot="label">Cancel</span>
    </button>
    <button class="button" type="button" data-variant="solid" data-color="primary" data-size="md">
      <span data-slot="label">Save changes</span>
    </button>
  </div>
</dialog>
  • The size is mirrored as data-size, which is always there. The role is only written out for an alert dialog, since dialog is the element's own.
  • Parts are marked with data-slot and always come in the same order: header, holding title, close and description, then body and actions. Only the header and the title are always there.
  • The title's and the description's IDs are the dialog's own, with -title and -description on the end. There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot.
  • Opening and closing change attributes, never elements. The dialog stays in the page while it's closed, so a form inside it keeps what people typed.
  • It's never open in the server's markup. It opens with showModal() once it's in the page, since the open attribute alone would open it as a non-modal dialog.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Dialog.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

They run in jsdom, which has the <dialog> element but not showModal() or close(), so the tests stand in for those two. A stand-in can't put the dialog in the top layer, make the page inert or keep Tab inside, so the checklist checks those in the browser instead.

In the browser

In the browser, you can run axe to check the dialog's name and contrast, and use the keyboard the way people do, to check that focus goes in, stays in and comes back:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the edit profile dialog takes focus and gives it back", async ({ page }) => {
  await page.goto("/profile");

  const opener = page.getByRole("button", { name: "Edit profile" });
  await opener.focus();
  await page.keyboard.press("Enter");

  const dialog = page.getByRole("dialog", { name: "Edit profile" });
  await expect(dialog.getByRole("textbox", { name: "Full name" })).toBeFocused();

  // Tab goes round the dialog's controls, and never reaches the page behind it.
  for (let i = 0; i < 6; i++) {
    await page.keyboard.press("Tab");
    await expect(opener).not.toBeFocused();
  }

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);

  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();
});

Some things still need a human, so try your dialogs with the keyboard alone, with a screen reader, which should read the title and description as focus moves in, on a phone, at 200% zoom and with forced colours, more contrast and reduced motion turned on. You can emulate the last three in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as a dialog renders without a title, which leaves it without a name.

Prompts

You can point your agent straight at this page. To build a dialog, you could use:

Build a Dialog component for our React design system that meets the Open Components
Dialog standard: https://opencomponents.dev/raw/docs/components/dialog.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Dialog against the Open Components Dialog contract:
https://opencomponents.dev/raw/docs/components/dialog.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its dialogs:

Review how our settings page uses its dialogs against the Open Components Dialog
contract: https://opencomponents.dev/raw/docs/components/dialog.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A dialog meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Dialog itself, so they're the ones to check when you build or review one. Usage rules are met by the screens that use it, like giving every dialog a title, so they're the ones to check when you review a screen. Both rules need the two to work together, like focus on an alert dialog's least destructive action: with no controls in its body, the Dialog focuses the first action, as long as you put that one first.

The basis says where a rule comes from. Many of the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and several come from the APG's dialog patterns and from HTML. The rest are Open Components rules, which are our own, like never closing a dialog without asking you. Where a rule goes further than WCAG asks at level AA, its basis says so: one comes from level AAA, reduced motion, and a rule that asks for more than its criterion says beyond, like using a native <dialog>, where WCAG would let you rebuild one by hand.

UI rules

RuleLevelScopeRequirementBasisCheck
dialog/contrastMustComponentThe title, the description and the content have a contrast of 4.5:1 against the panelWCAG 1.4.3 (AA)axe color-contrast, contrast checker
dialog/backdropShouldComponentA backdrop dims the page behind an open dialog, and a border sets the panel apart where the backdrop barely showsOpen ComponentsReview
dialog/fits-screenMustComponentIt's never wider or taller than the screen, less a margin, down to 320px wideWCAG 1.4.10 (AA)Emulation
dialog/resizableMustComponentIt's sized in rem, so it grows with its text, and its title wraps rather than getting cut offOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
dialog/actions-in-viewShouldComponentThe title and the actions stay in view, and only the body scrolls, when the content doesn't fitOpen ComponentsEmulation
dialog/fitting-sizeShouldUsageIts size fits its content, like sm for a confirmation and lg for a long formOpen ComponentsReview
dialog/reduced-motionShouldComponentIt only animates as it opens when prefers-reduced-motion is no-preferenceWCAG 2.3.3 (AAA)Emulation
dialog/forced-colorsMustComponentThe panel's border stays visible in forced colours modeOpen ComponentsEmulation
dialog/more-contrastShouldComponentThe panel's border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
dialog/mirrors-in-rtlShouldComponentIt uses logical properties, so its close button and actions mirror in right-to-left languagesOpen ComponentsReview

UX rules

RuleLevelScopeRequirementBasisCheck
dialog/native-elementMustComponentIt's a native <dialog>, opened with showModal(), and never a <div> with role="dialog"Open Components, beyond WCAG 2.1.2, 4.1.2 (A)Unit test
dialog/modalMustComponentWhile it's open, it's in the top layer and the rest of the page is inert, so nothing behind it can be focused, clicked or readAPGKeyboard, screen reader
dialog/contain-focusMustComponentTab and Shift+Tab never move focus to the page behind an open dialogWCAG 2.4.3 (A), APGKeyboard
dialog/namedMustComponentIt's named by its title, with aria-labelledbyWCAG 4.1.2 (A)Unit test
dialog/visible-titleMustUsageEvery dialog has a visible title that says what it's for, like "Delete project?"WCAG 1.3.1 (A), 2.4.6 (AA)Review
dialog/describedShouldBothA short description is linked with aria-describedby, while longer content, like a form, isn'tAPGUnit test
dialog/initial-focusMustBothOpening it moves focus inside, to the control marked with data-autofocus, or else the first in its body, then its first actionWCAG 2.4.3 (A), APGUnit test
dialog/safe-focusMustBothAn alert dialog's initial focus is on its least destructive actionAPGUnit test, review
dialog/return-focusMustBothClosing it moves focus back to the control that opened it, or to a logical place when that control is goneWCAG 2.4.3 (A), APGUnit test, keyboard
dialog/escapeMustComponentEscape asks to close every dialog, alert dialogs included, unless a control inside handles it firstAPGUnit test
dialog/close-buttonMustComponentA dialog, but not an alert dialog, has a visible close button, a Button named "Close"Open ComponentsUnit test
dialog/backdrop-dismissShouldComponentA press that starts and ends on the backdrop asks to close a dialog, but never an alert dialogOpen ComponentsUnit test
dialog/way-outMustUsageEvery alert dialog has an action that leaves things as they are, like "Keep project"Open ComponentsReview
dialog/outcome-namesShouldUsageIts actions are named by what they do, like "Delete project", rather than "Yes" or "OK"Open ComponentsReview
dialog/keep-changesShouldUsageA dialog with unsaved changes asks before it closes, rather than losing themOpen ComponentsReview
dialog/scroll-lockShouldComponentThe page behind an open dialog doesn't scroll, and doesn't shift when its scrollbar goesOpen ComponentsEmulation
dialog/use-sparinglyShouldUsageIt's only used for what needs an answer before people go on, and never opens on its own, like on page loadOpen ComponentsReview
dialog/one-at-a-timeShouldUsageAt most one dialog opens on top of another, and it closes firstOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
dialog/shared-vocabularyMustComponentIt uses the shared names: open, size, actions and close (closeRequest in Angular)Open ComponentsReview
dialog/typedMustComponentProps, events and slots are typed, with unions rather than string, and id and title (dialogTitle in Angular) are requiredOpen ComponentsType check
dialog/controllableMustComponentIt opens and closes with open, and asks rather than closing itself: Escape, the close button and the backdrop fire close, with why, and when a browser closes it anyway, it fires close with closedOpen ComponentsUnit test
dialog/root-attributesMustComponentAttributes, classes and listeners land on the <dialog>Open ComponentsUnit test
dialog/form-actionsShouldComponentA <form method="dialog"> inside it fires close with the reason action and its button's value, rather than closing itHTMLUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
dialog/role-and-nameMustComponentIt can be found by its role and title alone, with getByRole("dialog", { name }), or "alertdialog"WCAG 4.1.2 (A)Unit test, ARIA snapshot
dialog/hidden-when-closedMustComponentA closed dialog isn't in the accessibility tree, so neither agents nor screen readers find itHTMLUnit test
dialog/deterministic-domMustComponentThe same props render the same markup, with its parts' IDs made from its own, and opening it changes attributes, never elementsOpen ComponentsUnit test
dialog/data-attributesShouldComponentThe size is mirrored as data-size, and parts are marked with data-slotOpen ComponentsUnit test
dialog/dev-warningsShouldComponentIt warns during development when it has no titleOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It uses the Button for its close button, so copy the Button and its tokens.css into your theme too.

The tests are in Dialog.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom. jsdom doesn't have showModal() or close() yet, so the tests stand in for them, at the top of the file.

<script setup lang="ts">
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
import Button from "../button/Button.vue";

/**
 * Why people asked to close the dialog, or `closed` when it's already closed, like when
 * the browser closed it itself.
 */
export type DialogCloseReason = "escape" | "close-button" | "backdrop" | "action" | "closed";

export interface DialogProps {
  /** Shows the dialog, with `showModal()`. It never changes on its own, so set it to false when `close` fires. */
  open?: boolean;
  /** The dialog's ID, which its title's and description's start with, so they're the same on the server and the client. */
  id: string;
  /** The heading that names the dialog, like "Delete project?". */
  title: string;
  /** A sentence or two on what the dialog is about, which screen readers read along with its title. */
  description?: string;
  /** `alertdialog` for a confirmation that needs an answer, which has no close button and ignores the backdrop. */
  role?: "dialog" | "alertdialog";
  /** How wide it gets, at most. */
  size?: "sm" | "md" | "lg";
  /** The close button's label, which screen readers read and voice control users say. */
  closeLabel?: string;
}

const {
  open = false,
  id,
  title,
  description,
  role = "dialog",
  size = "md",
  closeLabel = "Close",
} = defineProps<DialogProps>();

const emit = defineEmits<{
  /**
   * People asked to close the dialog, and why. `value` is the value of the button that
   * submitted a `<form method="dialog">` inside it, and empty for any other reason.
   */
  close: [reason: DialogCloseReason, value: string];
}>();

defineSlots<{
  /** The content, like a message or a form. */
  default?: () => unknown;
  /** The Buttons that answer the dialog, like Cancel and Save, in the order people read them. */
  actions?: () => unknown;
}>();

const dialog = useTemplateRef<HTMLDialogElement>("dialog");

// Whatever had focus before the dialog opened, which gets it back when it closes.
let opener: HTMLElement | null = null;

const FOCUSABLE =
  'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';

// The control people marked with `data-autofocus`, or else the first one in the body,
// then the first action, which in an alert dialog is the least destructive one, and
// only then the close button, which would make people tab back past it to start.
// `data-autofocus` works the same in every framework, while React focuses an
// `autoFocus` element as it renders, before the dialog opens, rather than marking it.
// Browsers honour `autofocus` in a dialog too, so it counts as well.
function initialFocus(el: HTMLDialogElement) {
  return (
    el.querySelector<HTMLElement>("[data-autofocus], [autofocus]") ??
    el.querySelector('[data-slot="body"]')?.querySelector<HTMLElement>(FOCUSABLE) ??
    el.querySelector('[data-slot="actions"]')?.querySelector<HTMLElement>(FOCUSABLE) ??
    el.querySelector<HTMLElement>('[data-slot="close"]')
  );
}

// showModal() puts the dialog in the top layer, above everything else, and makes the
// rest of the page inert, so focus, clicks and screen readers stay inside it.
function show() {
  const el = dialog.value;
  if (!el || el.open) return;
  opener = document.activeElement as HTMLElement | null;
  el.showModal();
  initialFocus(el)?.focus();
}

function hide() {
  if (dialog.value?.open) dialog.value.close();
  restoreFocus();
}

// Back to whatever opened the dialog, so keyboard and screen reader users carry on where
// they left off. A control that's gone from the page can't take focus, so then it's up
// to the page to move it somewhere that makes sense.
function restoreFocus() {
  opener?.focus();
  opener = null;
}

watch(
  () => open,
  (value) => (value ? show() : hide()),
  { flush: "post" },
);

// Escape is handled on keydown, rather than through the dialog's `cancel`, so a control
// inside that closes on Escape first, like a menu, can stop it with preventDefault().
function onKeydown(event: KeyboardEvent) {
  if (event.key !== "Escape" || event.defaultPrevented) return;
  event.preventDefault();
  emit("close", "escape", "");
}

// Other close requests, like the back gesture on Android, ask too, rather than closing it.
// A browser that won't let one be turned down, without a press or a key since the last,
// makes its `cancel` uncancelable and closes the dialog itself, which `onClose` reports.
function onCancel(event: Event) {
  if (!event.cancelable) return;
  event.preventDefault();
  emit("close", "escape", "");
}

// When the browser closed the dialog itself, it can't be turned down, so it says so with
// a reason of its own, `closed`, for `open` to follow, and gives focus back. A `close`
// that arrives once it's open again, like after closing and reopening it in one go, is
// one it already handled.
function onClose() {
  if (dialog.value?.open) return;
  restoreFocus();
  if (open) emit("close", "closed", "");
}

// A <form method="dialog"> would close the dialog itself. It asks instead, with the
// value of the button that submitted it, like `delete`. A button's own `formmethod`
// wins over its form's `method`, as it does when the form is sent.
function onSubmit(event: SubmitEvent) {
  const submitter = event.submitter as HTMLButtonElement | null;
  const method = submitter?.getAttribute("formmethod")?.toLowerCase() ?? (event.target as HTMLFormElement).method;
  if (method !== "dialog" || event.defaultPrevented) return;
  event.preventDefault();
  emit("close", "action", submitter?.value ?? "");
}

// A click on the backdrop lands on the <dialog>, outside its box. Both the press and the
// click have to be there, so selecting text and letting go outside doesn't close it.
let pressedBackdrop = false;

function onBackdrop(event: MouseEvent) {
  const el = dialog.value!;
  const box = el.getBoundingClientRect();
  const { clientX: x, clientY: y } = event;
  return event.target === el && (x < box.left || x > box.right || y < box.top || y > box.bottom);
}

function onMousedown(event: MouseEvent) {
  pressedBackdrop = onBackdrop(event);
}

function onClick(event: MouseEvent) {
  // An alert dialog needs an answer, so a stray click outside it doesn't count as one.
  if (role === "dialog" && pressedBackdrop && onBackdrop(event)) emit("close", "backdrop", "");
  pressedBackdrop = false;
}

onMounted(() => {
  if (open) show();

  if (import.meta.env.DEV && !title.trim()) {
    console.warn("[Dialog] has no title: give it one, since it's what names the dialog.", dialog.value);
  }
});

onBeforeUnmount(hide);
</script>

<template>
  <dialog
    :id="id"
    ref="dialog"
    class="dialog"
    :role="role === 'alertdialog' ? 'alertdialog' : undefined"
    :aria-labelledby="`${id}-title`"
    :aria-describedby="description ? `${id}-description` : undefined"
    :data-size="size"
    @keydown="onKeydown"
    @cancel="onCancel"
    @close="onClose"
    @submit="onSubmit"
    @mousedown="onMousedown"
    @click="onClick"
  >
    <div data-slot="header">
      <h2 :id="`${id}-title`" data-slot="title">{{ title }}</h2>
      <Button
        v-if="role === 'dialog'"
        data-slot="close"
        :label="closeLabel"
        variant="ghost"
        size="sm"
        icon-only
        @click="emit('close', 'close-button', '')"
      >
        <template #leading>
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        </template>
      </Button>
      <p v-if="description" :id="`${id}-description`" data-slot="description">{{ description }}</p>
    </div>
    <div v-if="$slots.default" data-slot="body">
      <slot />
    </div>
    <div v-if="$slots.actions" data-slot="actions">
      <slot name="actions" />
    </div>
  </dialog>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The dialog's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

/* The panel looks like the Card: the same padding, spacing, radius, border and type.
   Only its background differs, since it has to cover the page. */
@layer components {
  .dialog {
    /* Set per size. */
    --dialog--width: 32rem;
    --dialog--padding: 1.25rem;
    --dialog--gap: 0.75rem;
    --dialog--radius: 0.75rem;
    /* The panel's border is decoration: the backdrop and the title say what it is, so it
       doesn't need 3:1. */
    --dialog--border-color: color-mix(in srgb, currentColor 20%, transparent);
    /* The native dialog's own background, which follows `color-scheme`, so it covers the page. */
    --dialog--background: Canvas;
    /* The page behind it, dimmed, so people can tell it's out of reach until they're done. */
    --dialog--backdrop--background-color: color-mix(in srgb, #000 50%, transparent);

    box-sizing: border-box;
    /* Never wider or taller than the screen, less a margin, so it fits at 320px wide and at 200% zoom. */
    width: min(var(--dialog--width), 100% - 2rem);
    max-width: none;
    max-height: calc(100% - 2rem);
    margin: auto;
    padding: var(--dialog--padding);
    border: 1px solid var(--dialog--border-color);
    border-radius: var(--dialog--radius);
    background-color: var(--dialog--background);
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: 0.875rem;
    line-height: 1.5;
    /* Long words, like a web address, wrap rather than overflow the panel. */
    overflow-wrap: break-word;
  }
  /* Only while it's open, since a closed dialog stays hidden by the browser's own styles. */
  .dialog[open] {
    display: flex;
    flex-direction: column;
    gap: var(--dialog--gap);
  }
  .dialog::backdrop {
    background-color: var(--dialog--backdrop--background-color);
  }

  /* Sizes */
  .dialog[data-size="sm"] {
    --dialog--width: 24rem;
  }
  .dialog[data-size="lg"] {
    --dialog--width: 48rem;
  }

  /* Parts. They have no borders of their own, like the Card's. The title and the actions
     stay in view, and the body scrolls between them. */
  .dialog > [data-slot="header"] {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: start;
    gap: 0.25rem 1rem;
  }
  .dialog [data-slot="title"] {
    margin: 0;
    font-size: 1rem;
    font-weight: 600;
    line-height: 1.375;
  }
  .dialog [data-slot="close"] {
    grid-column: 2;
    grid-row: 1;
    /* Centred on the title's first line, and lined up with the edge of the content. */
    margin-block: calc((1.375rem - 2rem) / 2);
    margin-inline-end: -0.5rem;
  }
  .dialog [data-slot="description"] {
    grid-column: 1 / -1;
    margin: 0;
    /* The text colour at 70%, which has 4.5:1 against the panel. */
    color: color-mix(in srgb, currentColor 70%, transparent);
  }
  .dialog > [data-slot="body"] {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Room for the focus rings of the controls at its edges, which it would clip. */
    margin: -0.25rem;
    padding: 0.25rem;
  }
  .dialog > [data-slot="actions"] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  /* A <form method="dialog"> around the actions lays them out as if it weren't there. */
  .dialog > [data-slot="actions"] > form {
    display: contents;
  }

  /* The page behind doesn't scroll while a dialog is open, and keeps its scrollbar's
     space, so it doesn't shift sideways. */
  :global(:root:has(.dialog:modal)) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }

  /* It fades and grows in, unless people have asked for less motion. */
  @media (prefers-reduced-motion: no-preference) {
    .dialog[open],
    .dialog[open]::backdrop {
      transition:
        opacity 150ms ease-out,
        scale 150ms ease-out;
    }
    @starting-style {
      .dialog[open] {
        opacity: 0;
        scale: 0.96;
      }
      .dialog[open]::backdrop {
        opacity: 0;
      }
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .dialog {
      --dialog--border-color: currentColor;
    }
  }
}
</style>

FAQ

Dialog or something else?

A dialog interrupts people, so it's best kept for what needs an answer before they go on. For anything else, another component will do a better job:

When you need to…Use
Ask a question people answer before they go on, like a deleteDialog, with role="alertdialog"
Edit something without leaving the page, like a profileDialog
Show more next to what opened it, without blocking the pagePopover
Offer a few actions behind one buttonDropdown Menu
Confirm that something happened, like "Changes saved"Toast
Edit something with many fields, or that people come back toA page of its own

The Popover shares the Dialog's rules for focus, Escape and the top layer, and the Dropdown Menu and the Toast on our Roadmap will too.

Sources

Copyright © 2026