Skip to content

Accessibility specialists

  • About
  • Services
  • Blog
  • Resources
Email us
  1. Home
  2. Resources
  3. Accessible components library
  4. Dialog

Pages in this section

Components

  • Accordion
  • Breadcrumbs
  • Dialog
    • Accessibility requirements
    • Test scenarios
  • Disclosure
  • Pagination
  • Tabs
  • Text input
  • Textarea

More information

  • Accessibility requirements
  • Compatibility notes
  • Glossary
  • Test steps
  • View the project on GitHub

Dialog

A dialog component appears over the top of the main content to present information or request user input. It can be modal or non-modal:

  • Modal dialogs demand immediate attention by blocking interaction with the rest of the page. When a modal dialog is open, all content behind it becomes inert, the page cannot be scrolled, and keyboard focus is contained within it.

  • Non-modal dialogs allow people to still interact with the page content behind them.

It should always be easy and obvious how to dismiss a dialog. As well as using clear controls, we recommend adding an event listener to close the dialog using the Escape key.

Example

Open this example in a new tab
  • HTML
  • CSS
  • JavaScript
<h1>Dialog</h1>

<p>
  A modal dialog blocks interaction with the rest of the page. This paragraph
  contains a focusable link to
  <a class="demo-link" href="https://tetralogical.com">the TetraLogical website</a>
  for demo purposes, to show that it becomes inert and is not focusable while
  the dialog is open.
</p>

<button type="button" id="dialog-trigger" class="tl-dialog__button" command="show-modal" commandfor="dialog-modal">
  Open modal
</button>

<dialog id="dialog-modal" class="tl-dialog" tabindex="-1" aria-modal="true" aria-labelledby="dialog-modal-heading">
  <h2 id="dialog-modal-heading">
    Do you want to delete your account?
  </h2>

  <p>
    This is a demo. No data will actually be deleted.
  </p>

  <div class="tl-dialog__actions">
    <button type="button" id="dialog-cancel" class="tl-dialog__button" command="close" commandfor="dialog-modal">
      No
    </button>

    <button type="button" id="dialog-confirm" class="tl-dialog__button tl-dialog__button--destructive" command="close" commandfor="dialog-modal">
      Yes
    </button>
  </div>
</dialog>
/* -------------------------------------------------------------------------- */
/* Dialog                                                                     */
/* -------------------------------------------------------------------------- */

/* Tokens are declared on the dialog and on the controls which sit outside it */
.tl-dialog,
.tl-dialog__button,
.tl-dialog__close {
  --colour-dialog-text: var(--tl-colour-text, #333333);
  --colour-dialog-background: var(--tl-colour-page, #ffffff);
  --colour-dialog-border: var(--tl-colour-border, #666666);

  --colour-button-text: var(--tl-colour-text, #333333);
  --colour-button-background: var(--tl-colour-surface, #f0f0eb);
  --colour-button-border: var(--tl-colour-border, #666666);
  --colour-button-destructive-border: var(--tl-colour-error, #ca3535);

  --colour-focus-highlight: var(--tl-colour-highlight-focus, #7f238b);
  --colour-focus-contrasting: var(--tl-colour-page, #ffffff);

  --border-width: 2px;
}

/* The dialog surface, shared by the modal and non-modal variants */
.tl-dialog {
  max-inline-size: min(35rem, calc(100vw - 2rem));
  padding: 1.5rem;
  color: var(--colour-dialog-text);
  background-color: var(--colour-dialog-background);
  border: var(--border-width) solid var(--colour-dialog-border);
}

/* Dim the page behind a modal dialog */
.tl-dialog::backdrop {
  background-color: rgb(0 0 0 / 60%);
}

/* The browser makes the page behind a modal dialog inert, but still lets it
   scroll, so lock it in place until the dialog is dismissed */
html:has(.tl-dialog:modal) {
  overflow: hidden;
}

/* A non-modal dialog is open in the markup, so that it can be read without the
   script. The browser would position it absolutely and centre it, so until the
   script has taken over it is made a plain block in the flow of the page */
.tl-dialog--non-modal:not([data-enhanced]) {
  position: static;
  inline-size: auto;
  margin: 0;
}

/* Once the script has closed the non-modal dialog and given it a data-enhanced
   attribute, it sits out of the way in the corner of the page */
.tl-dialog--non-modal[data-enhanced] {
  position: fixed;
  inset-block: auto 1rem;
  inset-inline: auto 1rem;
  margin: 0;
}

/* When a non-modal dialog completely covers the element which has keyboard
   focus, the script gives it a data-focus-behind attribute and a
   --dialog-focus-offset custom property. The offset moves the dialog along
   from the inline start of the viewport, so that it begins just past the
   focused element and the focus indicator can be seen.

   The dialog keeps its size and is allowed to run off the edge of the viewport.
   In this moment, uncovering the focus indicator matters more than keeping the 
   whole dialog in view, and at narrow widths there is nowhere else for it to go. 
   Sizing it to max-content keeps it the width it already was; otherwise it would 
   shrink to fit the space left between the offset and the edge of the viewport, 
   and grow much taller. */
.tl-dialog--non-modal[data-focus-behind] {
  inset-inline: var(--dialog-focus-offset, auto) auto;
  inline-size: max-content;
}

/* The dialog itself is focused when it opens, so it needs no indicator of its own */
.tl-dialog:focus-visible {
  outline: none;
}

.tl-dialog h2 {
  margin-block: 0 1rem;
}

/* The close button comes last in the markup, so people reach the content before
   it and it is the last control before leaving the dialog. It still sits in the
   top corner, where people expect to find it, with the heading kept clear of it */
.tl-dialog__close {
  position: absolute;
  inset-block-start: 1.5rem;
  inset-inline-end: 1.5rem;
}

.tl-dialog:has(.tl-dialog__close:not([hidden])) h2 {
  padding-inline-end: calc(44px + 1rem);
}

/* Row of buttons which dismiss the dialog */
.tl-dialog__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin-block-start: 1.5rem;
}

/* Controls which open and dismiss the dialog */
.tl-dialog__button,
.tl-dialog__close {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  font: inherit;
  color: var(--colour-button-text);
  cursor: pointer;
  background-color: var(--colour-button-background);
  border: var(--border-width) solid var(--colour-button-border);
}

/* Hidden controls stay hidden, whatever display value is set above */
.tl-dialog__button[hidden],
.tl-dialog__close[hidden] {
  display: none;
}

/* Destructive actions are marked out with the error colour */
.tl-dialog__button--destructive {
  border-color: var(--colour-button-destructive-border);
}

/* The icon-only close button is square, so it needs no horizontal padding */
.tl-dialog__close {
  padding: 0.5rem;
}

.tl-dialog__close svg {
  inline-size: 24px;
  block-size: 24px;
}

/* Focus styles, which stay visible against light and dark backgrounds */
.tl-dialog__button:focus-visible,
.tl-dialog__close:focus-visible {
  outline: 3px solid var(--colour-focus-contrasting);
  box-shadow: inset 0 0 0 3px var(--colour-focus-highlight);
}

/* Demo content. The link sits outside the dialog, so it uses the page tokens */
a.demo-link {
  color: var(--tl-colour-text-link);
}

a.demo-link:focus-visible {
  outline: 3px solid var(--tl-colour-highlight-focus);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--tl-colour-page);
}
/**
 * Dialog
 *
 * Built on the native <dialog> element. Controls point at their dialog with
 * commandfor and say what they do with command: "show-modal" opens a modal,
 * which makes the rest of the page inert and keeps keyboard focus inside the
 * dialog, "--show" opens a non-modal dialog which leaves the page interactive,
 * and "close" closes either. A modal dialog is also marked aria-modal="true".
 *
 * Browsers which support invoker commands handle "show-modal" and "close"
 * themselves, so a modal works without this script. "--show" is a custom
 * command with no native behaviour, so a non-modal dialog is open in the
 * markup with its controls hidden: without the script it reads as a block of
 * content where it sits in the page. This script closes it, reveals its
 * controls and handles every command itself, so that the behaviour is the
 * same in every browser.
 *
 * A non-modal dialog sits over the page, so it can hide the focus indicator of
 * whatever the user tabs to behind it. While that is the case the dialog is
 * given a data-focus-behind attribute and a --dialog-focus-offset custom
 * property, which the stylesheet uses to move it clear.
 */

// The focus indicator is drawn this far outside an element's box, so a dialog
// has to cover that much beyond the element before the indicator is hidden
const FOCUS_RING_ALLOWANCE = 6;

// Elements which can take keyboard focus, before checking whether they are
// disabled, hidden or taken out of the tab order
const FOCUSABLE =
  'a[href], button, input, select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable="true"], [tabindex]';

// The control which opened each dialog, so focus can go back to it on close
const openedBy = new WeakMap();

function isModal(dialog) {
  return dialog.getAttribute("aria-modal") === "true";
}

// The elements inside the dialog which are in the tab order, in DOM order
function tabbableElements(dialog) {
  return [...dialog.querySelectorAll(FOCUSABLE)].filter(
    (element) =>
      element.tabIndex >= 0 &&
      !element.matches(":disabled") &&
      element.getClientRects().length > 0,
  );
}

// The browser makes the page behind a modal dialog inert, but still lets Tab
// carry focus out of the dialog to its own toolbar. Wrap it round instead, so
// that focus stays inside the dialog until it is dismissed
function containFocus(dialog, event) {
  if (event.key !== "Tab") return;

  const tabbable = tabbableElements(dialog);
  const active = document.activeElement;

  // With nothing inside to move to, focus stays on the dialog itself
  if (!tabbable.length) {
    event.preventDefault();
    dialog.focus();
    return;
  }

  const first = tabbable[0];
  const last = tabbable[tabbable.length - 1];

  if (event.shiftKey && (active === first || active === dialog)) {
    // Shift+Tab from the first control, or from the dialog itself, wraps to the last
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && active === last) {
    // Tab from the last control wraps to the first
    event.preventDefault();
    first.focus();
  }
}

// Whether the dialog completely hides the focus indicator of an element behind it
function completelyCovers(dialog, element) {
  if (!element || dialog.contains(element)) return false;
  // Only keyboard focus draws an indicator
  if (!element.matches(":focus-visible")) return false;

  const target = element.getBoundingClientRect();
  if (!target.width || !target.height) return false;

  // Measure the dialog in its resting position. A dialog which has already
  // moved aside covers nothing, so measuring it where it stands would send it
  // straight back on the next scroll, and aside again on the one after that
  const moved = dialog.hasAttribute("data-focus-behind");
  if (moved) dialog.removeAttribute("data-focus-behind");

  try {
    const cover = dialog.getBoundingClientRect();
    const covered =
      cover.top <= target.top - FOCUS_RING_ALLOWANCE &&
      cover.bottom >= target.bottom + FOCUS_RING_ALLOWANCE &&
      cover.left <= target.left - FOCUS_RING_ALLOWANCE &&
      cover.right >= target.right + FOCUS_RING_ALLOWANCE;
    if (!covered) return false;

    // The boxes overlap, but the dialog also has to be painted on top
    const x = (target.left + target.right) / 2;
    const y = (target.top + target.bottom) / 2;
    return dialog.contains(document.elementFromPoint(x, y));
  } finally {
    if (moved) dialog.setAttribute("data-focus-behind", "");
  }
}

// Move the dialog so that it starts just past the far edge of the element,
// leaving room for the focus indicator. The offset is an inline-start inset,
// so in a right-to-left dialog it is measured from the right of the viewport
function moveAside(dialog, element) {
  const target = element.getBoundingClientRect();
  const rightToLeft = getComputedStyle(dialog).direction === "rtl";
  const offset = rightToLeft
    ? document.documentElement.clientWidth - target.left + FOCUS_RING_ALLOWANCE
    : target.right + FOCUS_RING_ALLOWANCE;

  dialog.style.setProperty("--dialog-focus-offset", `${offset}px`);
  dialog.setAttribute("data-focus-behind", "");
}

function moveBack(dialog) {
  dialog.style.removeProperty("--dialog-focus-offset");
  dialog.removeAttribute("data-focus-behind");
}

// Keep a non-modal dialog clear of the focused element as focus moves, the page
// scrolls or resizes, and the dialog opens and closes
function keepClearOfFocus(dialog) {
  function update() {
    if (dialog.open && completelyCovers(dialog, document.activeElement)) {
      moveAside(dialog, document.activeElement);
    } else {
      moveBack(dialog);
    }
  }

  document.addEventListener("focusin", update);
  document.addEventListener("focusout", update);
  // Scroll events do not bubble, so listen during the capture phase to catch
  // scrolling inside any element on the page
  document.addEventListener("scroll", update, true);
  window.addEventListener("resize", update);
  new MutationObserver(update).observe(dialog, { attributeFilter: ["open"] });
}

document.querySelectorAll("[commandfor]").forEach((control) => {
  const dialog = document.getElementById(control.getAttribute("commandfor"));
  const command = control.getAttribute("command");
  if (!dialog || dialog.tagName !== "DIALOG") return;
  if (!["show-modal", "--show", "close"].includes(command)) return;

  control.addEventListener("click", (event) => {
    // Cancelling the click stops the browser running the command as well
    event.preventDefault();

    if (command === "close") {
      dialog.close();
      return;
    }

    openedBy.set(dialog, control);

    if (command === "show-modal") {
      dialog.showModal();
    } else {
      dialog.show();
    }

    // Focus the dialog itself, so that its name and content are announced
    dialog.focus();
  });
});

document.querySelectorAll("dialog").forEach((dialog) => {
  // Collapse a non-modal dialog which was open in the markup, and mark it so the
  // stylesheet can float it. The modal variant is closed in the markup already
  if (!isModal(dialog)) {
    if (dialog.open) dialog.close();
    dialog.setAttribute("data-enhanced", "");
  }

  // Reveal the controls which were hidden until there was a script to run them
  if (dialog.id) {
    document
      .querySelectorAll(`[commandfor="${CSS.escape(dialog.id)}"]`)
      .forEach((control) => {
        control.hidden = false;
      });
  }

  // Return focus to the control which opened the dialog, however it was closed
  dialog.addEventListener("close", () => {
    const trigger = openedBy.get(dialog);
    if (trigger) trigger.focus();
  });

  if (isModal(dialog)) {
    dialog.addEventListener("keydown", (event) => containFocus(dialog, event));
    return;
  }

  // The browser closes a modal dialog on Esc, but a non-modal one needs a hand
  dialog.addEventListener("keydown", (event) => {
    if (event.key !== "Escape") return;
    event.preventDefault();
    dialog.close();
  });

  keepClearOfFocus(dialog);
});
  • HTML
  • CSS
  • JavaScript
<h1>Dialog</h1>

<p>
  A non-modal dialog does not block interaction with the rest of the page. This
  paragraph contains a focusable link to
  <a class="demo-link" href="https://tetralogical.com">the TetraLogical website</a>
  for demo purposes, to show that it remains focusable while the dialog is open.
</p>

<button type="button" id="dialog-trigger" class="tl-dialog__button" command="--show" commandfor="dialog-non-modal" hidden>
  Open dialog
</button>

<dialog id="dialog-non-modal" class="tl-dialog tl-dialog--non-modal" tabindex="-1" aria-labelledby="dialog-non-modal-heading" open>
  <h2 id="dialog-non-modal-heading">Non-modal example</h2>

  <p>
    Non-modal dialog allows the user to still focus on background elements.
    We recommend allowing keyboard users to close the dialog using <kbd>Esc</kbd>
    as well as having a physical close button.
  </p>

  <button type="button" id="dialog-close" class="tl-dialog__close" command="close" commandfor="dialog-non-modal" hidden>
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
      <path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" />
    </svg>
    <span class="visually-hidden">Close</span>
  </button>
</dialog>
/* -------------------------------------------------------------------------- */
/* Dialog                                                                     */
/* -------------------------------------------------------------------------- */

/* Tokens are declared on the dialog and on the controls which sit outside it */
.tl-dialog,
.tl-dialog__button,
.tl-dialog__close {
  --colour-dialog-text: var(--tl-colour-text, #333333);
  --colour-dialog-background: var(--tl-colour-page, #ffffff);
  --colour-dialog-border: var(--tl-colour-border, #666666);

  --colour-button-text: var(--tl-colour-text, #333333);
  --colour-button-background: var(--tl-colour-surface, #f0f0eb);
  --colour-button-border: var(--tl-colour-border, #666666);
  --colour-button-destructive-border: var(--tl-colour-error, #ca3535);

  --colour-focus-highlight: var(--tl-colour-highlight-focus, #7f238b);
  --colour-focus-contrasting: var(--tl-colour-page, #ffffff);

  --border-width: 2px;
}

/* The dialog surface, shared by the modal and non-modal variants */
.tl-dialog {
  max-inline-size: min(35rem, calc(100vw - 2rem));
  padding: 1.5rem;
  color: var(--colour-dialog-text);
  background-color: var(--colour-dialog-background);
  border: var(--border-width) solid var(--colour-dialog-border);
}

/* Dim the page behind a modal dialog */
.tl-dialog::backdrop {
  background-color: rgb(0 0 0 / 60%);
}

/* The browser makes the page behind a modal dialog inert, but still lets it
   scroll, so lock it in place until the dialog is dismissed */
html:has(.tl-dialog:modal) {
  overflow: hidden;
}

/* A non-modal dialog is open in the markup, so that it can be read without the
   script. The browser would position it absolutely and centre it, so until the
   script has taken over it is made a plain block in the flow of the page */
.tl-dialog--non-modal:not([data-enhanced]) {
  position: static;
  inline-size: auto;
  margin: 0;
}

/* Once the script has closed the non-modal dialog and given it a data-enhanced
   attribute, it sits out of the way in the corner of the page */
.tl-dialog--non-modal[data-enhanced] {
  position: fixed;
  inset-block: auto 1rem;
  inset-inline: auto 1rem;
  margin: 0;
}

/* When a non-modal dialog completely covers the element which has keyboard
   focus, the script gives it a data-focus-behind attribute and a
   --dialog-focus-offset custom property. The offset moves the dialog along
   from the inline start of the viewport, so that it begins just past the
   focused element and the focus indicator can be seen.

   The dialog keeps its size and is allowed to run off the edge of the viewport.
   In this moment, uncovering the focus indicator matters more than keeping the 
   whole dialog in view, and at narrow widths there is nowhere else for it to go. 
   Sizing it to max-content keeps it the width it already was; otherwise it would 
   shrink to fit the space left between the offset and the edge of the viewport, 
   and grow much taller. */
.tl-dialog--non-modal[data-focus-behind] {
  inset-inline: var(--dialog-focus-offset, auto) auto;
  inline-size: max-content;
}

/* The dialog itself is focused when it opens, so it needs no indicator of its own */
.tl-dialog:focus-visible {
  outline: none;
}

.tl-dialog h2 {
  margin-block: 0 1rem;
}

/* The close button comes last in the markup, so people reach the content before
   it and it is the last control before leaving the dialog. It still sits in the
   top corner, where people expect to find it, with the heading kept clear of it */
.tl-dialog__close {
  position: absolute;
  inset-block-start: 1.5rem;
  inset-inline-end: 1.5rem;
}

.tl-dialog:has(.tl-dialog__close:not([hidden])) h2 {
  padding-inline-end: calc(44px + 1rem);
}

/* Row of buttons which dismiss the dialog */
.tl-dialog__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin-block-start: 1.5rem;
}

/* Controls which open and dismiss the dialog */
.tl-dialog__button,
.tl-dialog__close {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  font: inherit;
  color: var(--colour-button-text);
  cursor: pointer;
  background-color: var(--colour-button-background);
  border: var(--border-width) solid var(--colour-button-border);
}

/* Hidden controls stay hidden, whatever display value is set above */
.tl-dialog__button[hidden],
.tl-dialog__close[hidden] {
  display: none;
}

/* Destructive actions are marked out with the error colour */
.tl-dialog__button--destructive {
  border-color: var(--colour-button-destructive-border);
}

/* The icon-only close button is square, so it needs no horizontal padding */
.tl-dialog__close {
  padding: 0.5rem;
}

.tl-dialog__close svg {
  inline-size: 24px;
  block-size: 24px;
}

/* Focus styles, which stay visible against light and dark backgrounds */
.tl-dialog__button:focus-visible,
.tl-dialog__close:focus-visible {
  outline: 3px solid var(--colour-focus-contrasting);
  box-shadow: inset 0 0 0 3px var(--colour-focus-highlight);
}

/* Demo content. The link sits outside the dialog, so it uses the page tokens */
a.demo-link {
  color: var(--tl-colour-text-link);
}

a.demo-link:focus-visible {
  outline: 3px solid var(--tl-colour-highlight-focus);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--tl-colour-page);
}
/**
 * Dialog
 *
 * Built on the native <dialog> element. Controls point at their dialog with
 * commandfor and say what they do with command: "show-modal" opens a modal,
 * which makes the rest of the page inert and keeps keyboard focus inside the
 * dialog, "--show" opens a non-modal dialog which leaves the page interactive,
 * and "close" closes either. A modal dialog is also marked aria-modal="true".
 *
 * Browsers which support invoker commands handle "show-modal" and "close"
 * themselves, so a modal works without this script. "--show" is a custom
 * command with no native behaviour, so a non-modal dialog is open in the
 * markup with its controls hidden: without the script it reads as a block of
 * content where it sits in the page. This script closes it, reveals its
 * controls and handles every command itself, so that the behaviour is the
 * same in every browser.
 *
 * A non-modal dialog sits over the page, so it can hide the focus indicator of
 * whatever the user tabs to behind it. While that is the case the dialog is
 * given a data-focus-behind attribute and a --dialog-focus-offset custom
 * property, which the stylesheet uses to move it clear.
 */

// The focus indicator is drawn this far outside an element's box, so a dialog
// has to cover that much beyond the element before the indicator is hidden
const FOCUS_RING_ALLOWANCE = 6;

// Elements which can take keyboard focus, before checking whether they are
// disabled, hidden or taken out of the tab order
const FOCUSABLE =
  'a[href], button, input, select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable="true"], [tabindex]';

// The control which opened each dialog, so focus can go back to it on close
const openedBy = new WeakMap();

function isModal(dialog) {
  return dialog.getAttribute("aria-modal") === "true";
}

// The elements inside the dialog which are in the tab order, in DOM order
function tabbableElements(dialog) {
  return [...dialog.querySelectorAll(FOCUSABLE)].filter(
    (element) =>
      element.tabIndex >= 0 &&
      !element.matches(":disabled") &&
      element.getClientRects().length > 0,
  );
}

// The browser makes the page behind a modal dialog inert, but still lets Tab
// carry focus out of the dialog to its own toolbar. Wrap it round instead, so
// that focus stays inside the dialog until it is dismissed
function containFocus(dialog, event) {
  if (event.key !== "Tab") return;

  const tabbable = tabbableElements(dialog);
  const active = document.activeElement;

  // With nothing inside to move to, focus stays on the dialog itself
  if (!tabbable.length) {
    event.preventDefault();
    dialog.focus();
    return;
  }

  const first = tabbable[0];
  const last = tabbable[tabbable.length - 1];

  if (event.shiftKey && (active === first || active === dialog)) {
    // Shift+Tab from the first control, or from the dialog itself, wraps to the last
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && active === last) {
    // Tab from the last control wraps to the first
    event.preventDefault();
    first.focus();
  }
}

// Whether the dialog completely hides the focus indicator of an element behind it
function completelyCovers(dialog, element) {
  if (!element || dialog.contains(element)) return false;
  // Only keyboard focus draws an indicator
  if (!element.matches(":focus-visible")) return false;

  const target = element.getBoundingClientRect();
  if (!target.width || !target.height) return false;

  // Measure the dialog in its resting position. A dialog which has already
  // moved aside covers nothing, so measuring it where it stands would send it
  // straight back on the next scroll, and aside again on the one after that
  const moved = dialog.hasAttribute("data-focus-behind");
  if (moved) dialog.removeAttribute("data-focus-behind");

  try {
    const cover = dialog.getBoundingClientRect();
    const covered =
      cover.top <= target.top - FOCUS_RING_ALLOWANCE &&
      cover.bottom >= target.bottom + FOCUS_RING_ALLOWANCE &&
      cover.left <= target.left - FOCUS_RING_ALLOWANCE &&
      cover.right >= target.right + FOCUS_RING_ALLOWANCE;
    if (!covered) return false;

    // The boxes overlap, but the dialog also has to be painted on top
    const x = (target.left + target.right) / 2;
    const y = (target.top + target.bottom) / 2;
    return dialog.contains(document.elementFromPoint(x, y));
  } finally {
    if (moved) dialog.setAttribute("data-focus-behind", "");
  }
}

// Move the dialog so that it starts just past the far edge of the element,
// leaving room for the focus indicator. The offset is an inline-start inset,
// so in a right-to-left dialog it is measured from the right of the viewport
function moveAside(dialog, element) {
  const target = element.getBoundingClientRect();
  const rightToLeft = getComputedStyle(dialog).direction === "rtl";
  const offset = rightToLeft
    ? document.documentElement.clientWidth - target.left + FOCUS_RING_ALLOWANCE
    : target.right + FOCUS_RING_ALLOWANCE;

  dialog.style.setProperty("--dialog-focus-offset", `${offset}px`);
  dialog.setAttribute("data-focus-behind", "");
}

function moveBack(dialog) {
  dialog.style.removeProperty("--dialog-focus-offset");
  dialog.removeAttribute("data-focus-behind");
}

// Keep a non-modal dialog clear of the focused element as focus moves, the page
// scrolls or resizes, and the dialog opens and closes
function keepClearOfFocus(dialog) {
  function update() {
    if (dialog.open && completelyCovers(dialog, document.activeElement)) {
      moveAside(dialog, document.activeElement);
    } else {
      moveBack(dialog);
    }
  }

  document.addEventListener("focusin", update);
  document.addEventListener("focusout", update);
  // Scroll events do not bubble, so listen during the capture phase to catch
  // scrolling inside any element on the page
  document.addEventListener("scroll", update, true);
  window.addEventListener("resize", update);
  new MutationObserver(update).observe(dialog, { attributeFilter: ["open"] });
}

document.querySelectorAll("[commandfor]").forEach((control) => {
  const dialog = document.getElementById(control.getAttribute("commandfor"));
  const command = control.getAttribute("command");
  if (!dialog || dialog.tagName !== "DIALOG") return;
  if (!["show-modal", "--show", "close"].includes(command)) return;

  control.addEventListener("click", (event) => {
    // Cancelling the click stops the browser running the command as well
    event.preventDefault();

    if (command === "close") {
      dialog.close();
      return;
    }

    openedBy.set(dialog, control);

    if (command === "show-modal") {
      dialog.showModal();
    } else {
      dialog.show();
    }

    // Focus the dialog itself, so that its name and content are announced
    dialog.focus();
  });
});

document.querySelectorAll("dialog").forEach((dialog) => {
  // Collapse a non-modal dialog which was open in the markup, and mark it so the
  // stylesheet can float it. The modal variant is closed in the markup already
  if (!isModal(dialog)) {
    if (dialog.open) dialog.close();
    dialog.setAttribute("data-enhanced", "");
  }

  // Reveal the controls which were hidden until there was a script to run them
  if (dialog.id) {
    document
      .querySelectorAll(`[commandfor="${CSS.escape(dialog.id)}"]`)
      .forEach((control) => {
        control.hidden = false;
      });
  }

  // Return focus to the control which opened the dialog, however it was closed
  dialog.addEventListener("close", () => {
    const trigger = openedBy.get(dialog);
    if (trigger) trigger.focus();
  });

  if (isModal(dialog)) {
    dialog.addEventListener("keydown", (event) => containFocus(dialog, event));
    return;
  }

  // The browser closes a modal dialog on Esc, but a non-modal one needs a hand
  dialog.addEventListener("keydown", (event) => {
    if (event.key !== "Escape") return;
    event.preventDefault();
    dialog.close();
  });

  keepClearOfFocus(dialog);
});

What problems it solves

  • Modal dialogs help focus people's attention on a specific task or decision, minimising distractions and reducing the risk of unintended actions, such as accidental deletions or submissions
  • Non-modal dialogs allow focused attention while still being able to see or interact with the rest of the page content as needed

When to use it

  • Modal dialog: When you need to interrupt the user flow to ask for input or confirmation, or to deliver important information that requires people's attention before they continue, for example confirming a delete action
  • Non-modal dialog: For supplementary content, secondary features, or when people still need to be able to access background content, for example a web chat

When not to use it

Do not use dialogs for:

  • Messages that will unnecessarily disrupt people, or obscure important context
  • Frequent, low-priority notifications
  • Multi-step processes

Where to use it

Modal dialogs visually open in the centre of the viewport, on top of everything else.

Place a modal dialog in the HTML as a direct child of the <body> element, as a modal dialog has to make the rest of the page inert, and it can only do that cleanly when nothing else is wrapped around it. Nested inside another element, it remains part of that element's landmark in the accessibility tree, and could become hidden or inert by inheriting from its ancestors. Any fallback script that makes the dialog's siblings inert would also leave the rest of the page interactive.

Non-modal dialogs visually sit in the bottom right corner of the viewport, where they are least likely to cover the content people are working with. However, if a non-modal dialog obscures the element that has keyboard focus, it temporarily moves out of the way.

Place a non-modal dialog in the HTML wherever it is most logical, ideally close to the control that opens it. Because the browser renders an open dialog on top of other elements, it's easy for it to feel detached, so its position in the markup is important to provide context for screen reader users.

How it works

The component is built using a native <dialog> element. Each control points at its dialog with the commandfor attribute and says what it does with the command attribute: show-modal opens a modal dialog, --show opens a non-modal one, and close closes either. A modal dialog makes the rest of the page inert and dims it, and also carries aria-modal="true". A non-modal dialog leaves the page interactive.

When a dialog opens, focus moves to the dialog itself, so that its name and content are announced together. When it closes, focus returns to the control that opened it, regardless of how it was dismissed. The Escape key also closes either dialog.

Inside a modal dialog, Tab wraps from the last control back to the first. The browser would otherwise let focus leave the dialog and enter the browser toolbar, which can be confusing while the page behind is inert.

A non-modal dialog sits over the page, but the page is still active. This means it can hide the focus indicator on elements behind it. When that happens the dialog temporarily moves out of the way, and moves back once focus has moved on.

Without JavaScript

The modal variant works without the script in browsers that support invoker commands, which are Chrome and Edge 135, Firefox 144 and Safari 26.2 or later. The browser opens the dialog from its trigger, closes it from its buttons or the Escape key, moves focus into it, and makes the page behind it inert. In older browsers the trigger does nothing and the dialog stays closed.

The non-modal variant has no native command to open it, so it is open in the markup by default with its controls hidden. Without the script, it renders as a block of content where it is placed in the DOM, which is why its position in the markup is important. When the script runs, it closes it, moves it to the corner of the viewport, and reveals the controls that open and close it, so modal content is never missing.

The script adds the following enhancements.

  • Focus moves to the dialog when it opens
  • Focus is returned to the trigger when it closes
  • Keyboard focus is contained inside a modal dialog and does not escape to the browser controls
  • Escape closes the non-modal dialog
  • The non-modal dialog temporarily moves when obscuring a focused element behind it

Assistive technology compatibility

This component has been tested with the following browsers and assistive technologies.

Assistive technology Result Notes
Keyboard No issues No notes
Voice Access/Chrome 154.0.8/Windows 11 No issues No notes
JAWS 2026.2603.86/Chrome 154.0.8/Windows 11 No issues No notes
NVDA 2026.1.1/Firefox 157.0/Windows 11 No issues No notes
VoiceOver/Safari macOS Tahoe 26.6 No issues No notes
VoiceOver/Safari/iOS 26.0 No issues No notes
TalkBack/Chrome 154.0.8/Android 17 No issues No notes
Windows Contrast themes No issues No notes
Windows Magnifier and/or Apple Zoom No issues No notes
Browser zoom No issues No notes
prefers-reduced-motion Not applicable There are no animations to reduce

Last updated: 13 October 2026.

Back to top

We like to listen

Wherever you are in your accessibility journey, get in touch if you have a project or idea.

hello@tetralogical.com
Colour scheme

60 Windsor Avenue, London, SW19 2RR, UK +44 (0)20 8895 6768 hello@tetralogical.com

© TetraLogical Services Ltd. All Rights Reserved. Company number 12951928.

Privacy policy • Accessibility statement • Code of ethics