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.

Create a free account to save this page, take notes and track your progress. Sign in

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 or aria-modal.
  • Closing on any click, including clicks inside the content (check @click.self).
  • Teleporting during SSR to targets that don't exist; body is safe, custom targets need defer or 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

Was this page helpful?