Vue 3.5 accessible dialog patterns

Modal dialogs are the single most common custom-component accessibility failure. Focus escapes; Escape does nothing; the heading is a <div>; the trigger button does not get focus back on close. Vue 3.5 has the tools to do this correctly out of the box — <dialog> for the 99% case, Teleport plus inert for the bespoke 1%.

Use native <dialog> whenever possible

The HTML <dialog> element, paired with showModal(), is now supported by every evergreen browser. It handles the hard parts for free: focus trap, Escape to close, backdrop, and aria-modal semantics.

<script setup lang="ts">
import { ref } from "vue";

const dlg = ref<HTMLDialogElement | null>(null);

function open() { dlg.value?.showModal(); }
function close() { dlg.value?.close(); }
</script>

<template>
  <button type="button" @click="open">Edit profile</button>

  <dialog ref="dlg" aria-labelledby="dlg-title">
    <form method="dialog" @submit.prevent="close">
      <h2 id="dlg-title">Edit profile</h2>

      <label for="name">Display name</label>
      <input id="name" name="name" type="text" />

      <menu class="actions">
        <button type="button" @click="close">Cancel</button>
        <button type="submit" value="save">Save</button>
      </menu>
    </form>
  </dialog>
</template>

Three things this gives you for free:

  1. Focus management. When showModal() runs, focus moves to the first focusable child. When the dialog closes, focus returns to the element that opened it.
  2. Focus trap. Tab and Shift+Tab cycle within the dialog only.
  3. Escape key closes the dialog.

You still need to label the dialog. aria-labelledby pointing to the <h2> is the cleanest approach; it gives screen readers “Edit profile, dialog” on open.

When <dialog> is not enough

Native <dialog> is opinionated about layout and styling. If your design requires a bottom-sheet, a side-drawer, or a non-modal popover, you may have to roll your own with Teleport and inert.

<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from "vue";

const open = ref(false);
const trigger = ref<HTMLButtonElement | null>(null);
const panel = ref<HTMLElement | null>(null);

function show() { open.value = true; }
function hide() { open.value = false; }

watch(open, async (v) => {
  await nextTick();
  if (v) {
    panel.value?.querySelector<HTMLElement>(
      "input, button, [tabindex='0']",
    )?.focus();
    document.body.style.overflow = "hidden";
  } else {
    trigger.value?.focus();
    document.body.style.overflow = "";
  }
});

function onKey(e: KeyboardEvent) {
  if (e.key === "Escape" && open.value) {
    e.preventDefault();
    hide();
  }
}

onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</script>

<template>
  <button ref="trigger" type="button" @click="show">Open settings</button>

  <Teleport to="body">
    <div
      v-if="open"
      class="backdrop"
      :inert="!open || undefined"
      @click="hide"
      aria-hidden="true"
    />
    <div
      v-if="open"
      ref="panel"
      role="dialog"
      aria-modal="true"
      aria-labelledby="settings-title"
      class="panel"
    >
      <h2 id="settings-title">Settings</h2>
      <slot />
      <button type="button" @click="hide">Close</button>
    </div>
  </Teleport>
</template>

Three things to watch:

  1. inert on the rest of the DOM. When the panel is open, set inert on everything outside it (the simplest approach: a sibling <div id="app-root"> gets inert toggled). This blocks Tab from escaping and hides the underlying content from screen readers. Use the vue-inert polyfill if you must support older Safari.
  2. Focus return on close. The watch callback re-focuses the trigger. Forgetting this strands the user.
  3. Keyboard Escape handler. The <dialog> element gets this for free; for Teleport, you must wire it up.

Focus trap

A correct focus trap cycles forward and backward through the dialog’s focusable elements. The simplest implementation:

function handleTab(e: KeyboardEvent) {
  if (e.key !== "Tab" || !panel.value) return;
  const focusable = panel.value.querySelectorAll<HTMLElement>(
    'a[href], button:not([disabled]), input:not([disabled]), ' +
    'select:not([disabled]), textarea:not([disabled]), [tabindex="0"]',
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];
  if (!first || !last) return;
  if (e.shiftKey && document.activeElement === first) {
    last.focus();
    e.preventDefault();
  } else if (!e.shiftKey && document.activeElement === last) {
    first.focus();
    e.preventDefault();
  }
}

Battle-tested libraries handle edge cases (radio groups, contenteditable, shadow DOM): focus-trap is the canonical choice.

Confirmation and destructive actions

Modal dialogs that confirm destructive actions (“Delete account?”) need two extra moves:

  • Default focus on the cancel button. Users who hit Enter on the trigger should not delete the account. Focus the safe option.
  • Announce the consequence in the heading or first paragraph. Not in a small subtitle below the button. Screen-reader users hear the dialog name first.

If you remember one rule: prefer the native <dialog>. Bespoke modal implementations almost always end up missing one piece of focus, escape, or label semantics — and that piece is what shows up in audit.