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:
- Focus management. When
showModal()runs, focus moves to the first focusable child. When the dialog closes, focus returns to the element that opened it. - Focus trap. Tab and Shift+Tab cycle within the dialog only.
- 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:
inerton the rest of the DOM. When the panel is open, setinerton everything outside it (the simplest approach: a sibling<div id="app-root">getsinerttoggled). This blocks Tab from escaping and hides the underlying content from screen readers. Use thevue-inertpolyfill if you must support older Safari.- Focus return on close. The
watchcallback re-focuses the trigger. Forgetting this strands the user. - Keyboard Escape handler. The
<dialog>element gets this for free; forTeleport, 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.
Cross-links
- Reference: Vue Teleport docs; MDN dialog element; WAI-ARIA APG dialog pattern.
- WCAG: How to fix WCAG 4.1.2 name role value, How to fix WCAG 2.4.7 focus visible.
- Adjacent framework: React 19 forms.
- Testing: How to test with NVDA — modals are where screen-reader smoke testing pays off.
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.