Vue cookbook · Intermediate
Build an accessible modal with Teleport
Modals render inside overflow-hidden containers, trap nobody's focus and can't be closed with Escape.
Approach
Use the native <dialog> element, rendered at the end of <body> with <Teleport>, open it with showModal()
(which handles focus trapping and Escape), and return focus to the trigger when it closes.
Solution
<!-- AppModal.vue -->
<script setup lang="ts">
import { nextTick, useTemplateRef, watch } from "vue";
const open = defineModel<boolean>("open", { required: true });
defineProps<{ title: string }>();
const dialog = useTemplateRef<HTMLDialogElement>("dialog");
let returnFocusTo: HTMLElement | null = null;
watch(open, async (isOpen) => {
await nextTick();
if (isOpen) {
returnFocusTo = document.activeElement as HTMLElement | null;
dialog.value?.showModal();
} else {
dialog.value?.close();
returnFocusTo?.focus();
}
});
</script>
<template>
<Teleport to="body">
<dialog ref="dialog" aria-labelledby="modal-title" @close="open = false" @click.self="open = false">
<h2 id="modal-title">{{ title }}</h2>
<slot />
<button type="button" @click="open = false">Close</button>
</dialog>
</Teleport>
</template>
Pitfalls
- Building modals from
<div>s without focus management oraria-modal. - Closing on any click, including clicks inside the content (check
@click.self). - Teleporting during SSR to targets that don't exist;
bodyis safe, custom targets needdeferor ClientOnly.
Variations
- Headless libraries (Reka UI, Headless UI) provide tested dialog primitives.
- Use
<Teleport defer>(Vue 3.5) to target elements rendered later in the same tick.
Further reading: Teleport
Reference: https://vuejs.org/guide/built-ins/teleport.html
More Vue recipes
- Access child component methods with template refs and defineExpose
- Add SEO metadata and structured data in Nuxt
- Build custom inputs with defineModel and modifiers
- Catch component errors with an error boundary
- Debounce user input with a custom ref
- Fetch data in a composable and cancel stale requests
- Fetch, refresh and share data with Nuxt useFetch
- Implement a dark mode toggle that respects system settings