Dialog

User Experience (UX)

How a dialog behaves for every person, input and setting, so it's accessible, predictable, clear in every state and adaptive.

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.

UX Checklist

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

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, the Dropdown Menu and the Toast are on our Roadmap, and they'll share the Dialog's rules for focus, Escape and the top layer.

Copyright © 2026