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, from its parts and how wide it gets to what goes in it, how it shows that it's open and its tokens.

User Experience (UX)

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

Developer Experience (DX)

How developers use the Dialog, with one consistent, typed API that composes with forms and other components and leaves when to open and close it to you.

Agentic Experience (AX)

How agents read, open and check a dialog, from its accessibility tree to its contract, its DOM and its tests.

At a glance

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, and it's on our Roadmap.

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>
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

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.

You'll find the rules for each layer on its page:

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>

Sources

Copyright © 2026