# Modal dialog

## When to use

A modal dialog blocks the page until the user answers it: confirm a delete, edit an address, sign in. If the user can keep working with the page behind it, build a non-modal panel or a disclosure instead. Do not use a modal for a plain notice; use a status message.

## Use the native dialog element

`<dialog>` opened with `showModal()` gives you, with no extra code: the dialog role, the top layer above every z-index, a backdrop, Escape to close, and everything outside the dialog made inert (not clickable, not focusable, hidden from screen readers). Hand-rolled modals almost always miss one of these.

```html
<button type="button" id="open-delete">Delete project</button>

<dialog
  id="delete-dialog"
  aria-labelledby="delete-title"
  aria-describedby="delete-desc"
>
  <h2 id="delete-title">Delete this project?</h2>
  <p id="delete-desc">
    The project and its 12 files are removed. This cannot be undone.
  </p>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete</button>
  </form>
</dialog>
```

```js
const opener = document.getElementById("open-delete");
const dialog = document.getElementById("delete-dialog");

opener.addEventListener("click", () => dialog.showModal());

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "delete") deleteProject();
  opener.focus();
});

dialog.addEventListener("click", (event) => {
  if (event.target === dialog) dialog.close("cancel");
});
```

`form method="dialog"` closes the dialog and sets `returnValue` from the pressed button, without a network request. Browsers return focus to the opener on close, but not when the opener was re-rendered or removed; the explicit `opener.focus()` covers that case.

## DO

- Open with `showModal()`. `show()` and the `open` attribute make a non-modal dialog: no inert background, no Escape.
- Name the dialog with `aria-labelledby` pointing at its visible heading. Add `aria-describedby` only for a short message the user must hear on open.
- Put initial focus on the first thing the user needs. For a destructive confirm, focus the safe button (Cancel). For a form, focus the first field. For long content, focus the heading with `tabindex="-1"` so the start is read first.
- Give every dialog a visible close or cancel control, as a real `<button>`.
- On close, move focus back to the control that opened it. If that control is gone (the row was deleted), focus the next logical element, such as the list heading.
- Lock page scroll while open (`body { overflow: hidden }` toggled on open and close) so the background does not move under the dialog.

## DO NOT

- Do not build a modal from a `div` with `role="dialog"` unless you also trap Tab, close on Escape, make the rest of the page `inert`, and restore focus. That is four things to get right; `<dialog>` does them.
- Do not put `aria-hidden="true"` on a container that holds the focused element or the dialog. Use `inert` on siblings if you must hide the background by hand.
- Do not add `aria-modal="true"` to a native `<dialog>` opened with `showModal()`; it is already modal. Add it only on a custom `role="dialog"`.
- Do not use `tabindex` values above 0 to force order inside the dialog. Order the DOM.
- Do not open a dialog on page load or on focus. Open it only from a user action.
- Do not close the dialog on outside click when it holds unsaved input; ask first or keep it open.

## React and frameworks

The most common bug: rendering `<dialog open>` from state. That is a non-modal dialog. Call the method in an effect instead:

```jsx
function Modal({ open, onClose, titleId, children }) {
  const ref = useRef(null);
  useEffect(() => {
    const dialog = ref.current;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);
  return (
    <dialog ref={ref} aria-labelledby={titleId} onClose={onClose}>
      {children}
    </dialog>
  );
}
```

Keep state in sync through `onClose`: Escape closes the dialog natively, and if React state still says `open`, the next open does nothing. Store the opener in a ref before opening if it may unmount.

## What to verify

- Tab and Shift+Tab reach only controls inside the dialog. With native `<dialog>`, Tab past the last control may go to the browser's own toolbar; that is expected. It must never land on page content behind the dialog.
- Escape closes it and focus lands on the opener.
- The accessibility tree shows a dialog with the heading text as its name.
- Content behind the dialog is not reachable with a screen reader's virtual cursor while it is open.
- At 400% zoom the dialog content scrolls inside the viewport and the close button stays reachable.

## WCAG success criteria

- [2.1.1 Keyboard (Level A)](https://www.w3.org/TR/WCAG21/#keyboard)
- [2.1.2 No Keyboard Trap (Level A)](https://www.w3.org/TR/WCAG21/#no-keyboard-trap)
- [2.4.3 Focus Order (Level A)](https://www.w3.org/TR/WCAG21/#focus-order)
- [4.1.2 Name, Role, Value (Level A)](https://www.w3.org/TR/WCAG21/#name-role-value)

## Demo

A working reference: https://good-a11y.com/guides/dialog-modal/demo.html

Retrieve this guide: `npx -y github:vojtaholik/good-a11y retrieve dialog-modal`
