Skip to content
Deep diveAdvanced Vue

Teleport: modals and tooltips that escape their parent

A modal that renders inside a card is at the mercy of that card's overflow and z-index. <Teleport> moves the markup to where it needs to be and leaves everything else where it was.

vuejs.school team9 Oct 20257 min readVue 3.5

You build a modal, open it from a button inside a card, and half of it is cut off. The CSS is right, the component is right, and still the overlay sits behind the header. The problem is not the modal; it is where the modal's HTML ends up.

<Teleport> fixes that. It renders the modal's HTML somewhere else, usually at the end of <body>, while the component stays where you wrote it, so its state, props, events and provide keep working. Put a native <dialog> inside it and the browser handles focus and the Escape key for you. The teleport's disabled prop keeps the markup in place when a narrow layout needs it inline, and its defer prop lets it point at an element that Vue renders later in the component tree.

Why a modal gets clipped

A modal wants two things from the page: to sit above everything, and to be positioned against the viewport. But its HTML is rendered wherever the component is used, usually several elements deep, and two kinds of CSS rule on any of those ancestors break it:

  • Clipping. overflow: hidden clips children that leave the box. A position: fixed overlay normally escapes it, because it is positioned against the viewport. But an ancestor with a transform, filter or perspective becomes the containing block for fixed descendants (the box they are positioned against), and then its overflow: hidden crops the overlay too.
  • Stacking. An ancestor with a position and a z-index creates a stacking context, and so does a transform. The modal's own z-index: 1000 then only competes with siblings inside that context, not with the site header outside it.

Here is the setup that fails. The card has overflow: hidden and a transform, as cards with a hover effect often do, and the modal is rendered inside it:

src/components/ProductCard.vueBreaks
<script setup lang="ts">
import { ref } from 'vue'

const open = ref(false)
</script>

<template>
  <article class="card">
    <h2>Desk lamp</h2>
    <button type="button" @click="open = true">Details</button>

    <div v-if="open" class="overlay" @click.self="open = false">
      <div class="panel">
        <p>Brass, 40 cm, dimmable.</p>
        <button type="button" @click="open = false">Close</button>
      </div>
    </div>
  </article>
</template>

<style scoped>
.card { overflow: hidden; transform: translateZ(0); }
.overlay { position: fixed; inset: 0; z-index: 1000; }
</style>

The overlay's position: fixed is now relative to the card, not the viewport, and overflow: hidden crops it at the card's edge. You cannot fix this from inside the modal, because the rules that break it are on the parent.

Move the DOM, not the component

<Teleport> renders its children into a different place in the document. Wrap the overlay and point to at a CSS selector or a DOM element. The script and the styles stay as they were; only the template changes:

src/components/ProductCard.vueWorks
<template>
  <article class="card">
    <h2>Desk lamp</h2>
    <button type="button" @click="open = true">Details</button>

    <Teleport to="body">
      <div v-if="open" class="overlay" @click.self="open = false">
        <div class="panel">
          <p>Brass, 40 cm, dimmable.</p>
          <button type="button" @click="open = false">Close</button>
        </div>
      </div>
    </Teleport>
  </article>
</template>

The overlay is now the last child of <body>, which you can confirm in the browser's Elements panel. No ancestor clips it, no stacking context traps it, and position: fixed means the viewport again.

What stays with the parent component

The open ref still lives in ProductCard, the @click handlers still set it, and the v-if still works. This is the part that trips people up. The Teleport guide states it directly: Teleport only alters the rendered DOM structure, it does not affect the logical hierarchy of the components. A component inside a <Teleport> is still a child of the component that contains the teleport:

  • Props go down and emitted events come up, as before.
  • inject() finds the values its parent provide()d.
  • Vue DevTools shows it under its parent, not under <body>.
  • Scoped styles from the parent still apply, because the scope attribute is stamped on the elements when they are created, not when they are placed.

The only things that change are the CSS rules that depend on ancestors, which is exactly what you wanted.

Let the native dialog handle focus and Escape

Once the markup can go anywhere, the next job is behaviour: trap focus inside the modal, close on Escape, and give focus back when it closes. The browser already does all three for a <dialog> opened with showModal(). The page behind it becomes inert, Escape fires a cancel event and then close, and on close the browser returns focus to the element that had it before. Here is that dialog inside a <Teleport>, reached through useTemplateRef() from Vue 3.5:

src/components/AppModal.vue
<script setup lang="ts">
import { onMounted, useTemplateRef, watch } from 'vue'

const props = defineProps<{ open: boolean, title: string }>()
const emit = defineEmits<{ close: [] }>()

const dialog = useTemplateRef<HTMLDialogElement>('dialog')

watch(() => props.open, (open) => {
  if (open) dialog.value?.showModal()
  else dialog.value?.close()
}, { flush: 'post' })

onMounted(() => {
  if (props.open) dialog.value?.showModal()
})
</script>

<template>
  <Teleport to="body">
    <dialog ref="dialog" class="modal" @close="emit('close')">
      <h2>{{ title }}</h2>
      <slot />
      <button type="button" @click="dialog?.close()">Close</button>
    </dialog>
  </Teleport>
</template>

The parent owns the state and passes it in: <AppModal :open="open" title="Details" @close="open = false">. Inside, three pieces keep the dialog in step with that prop:

  • The watch turns each change of open into a showModal() or close() call. flush: 'post' runs the callback after Vue has updated the DOM, so the title and slot content are current when the dialog opens.
  • onMounted covers a modal that is already open on the first render. The watcher only runs when open changes, so without onMounted that dialog would stay closed while the parent thinks it is open.
  • The close event is the single source of truth for closing. The Close button, the Escape key and any close() call all end up in it, and it tells the parent to set open back to false.

Because the <dialog> stays in the DOM and only its open state changes, there is no v-if and nothing to re-mount.

Style the backdrop with the ::backdrop pseudo-element, and remember that showModal() is what makes the dialog modal. show() or the open attribute gives you a non-modal dialog with no focus trap and no Escape handling.

Disabling the teleport for a mobile layout

Sometimes the same component should be a floating panel on a wide screen and an inline section on a narrow one: a filter drawer, a product gallery, a chat box. <Teleport> has a disabled prop for this. When it is true, the children render in place, as if the <Teleport> were not there, and the guide shows that it can be toggled at runtime:

src/components/FilterPanel.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const inline = ref(false)
const query = window.matchMedia('(max-width: 640px)')
const update = () => { inline.value = query.matches }

onMounted(() => {
  update()
  query.addEventListener('change', update)
})
onUnmounted(() => query.removeEventListener('change', update))
</script>

<template>
  <Teleport to="#drawer" :disabled="inline">
    <form class="filters">
      <slot />
    </form>
  </Teleport>
</template>

When the viewport crosses 640 pixels, Vue moves the existing elements between the two places. The <form> is not destroyed and recreated, so what the user typed stays. The window.matchMedia access sits in <script setup> here because this component only runs in the browser; in a server-rendered app, move it into onMounted as well.

Targets that render later: defer

to is resolved when the <Teleport> mounts, and the target must exist by then. A target outside the Vue app, such as body or a <div id="modals"> in index.html, always exists. A target rendered by Vue itself exists only if it mounted before the component that teleports into it. In this App.vue, the drawer target comes last:

src/App.vue
<template>
  <SiteHeader />
  <RouterView />
  <div id="drawer" />
</template>

Vue mounts a template from top to bottom. A FilterPanel that mounts in the same render as App.vue sits above #drawer in the tree, so its teleport runs before the target has been created, finds nothing, and warns Failed to locate Teleport target with selector "#drawer". The page inside <RouterView /> is part of that render when main.ts waits for router.isReady() before app.mount(); without the wait, the page mounts a moment later, after #drawer exists.

The defer prop is for exactly this case. With defer, the teleport waits until the rest of the current render has mounted before it looks for the target. In FilterPanel.vue, add it to the <Teleport>:

src/components/FilterPanel.vue
<Teleport defer to="#drawer" :disabled="inline">
  <form class="filters"><slot /></form>
</Teleport>

The guide puts one limit on it: the target must be rendered in the same mount or update tick as the teleport. defer behaves like the mounted hook, so a target that appears a second later, after a fetch, still produces the warning. For that case, render the teleport with v-if once the target exists.

Several teleports, one target

A page can have more than one teleport pointing at the same element, and that is the normal case for tooltips and toasts. The guide describes the result as a simple append: later mounts land after earlier ones, all inside the target. Two tooltips teleported to #tooltips render as two children of that element, in the order they mounted, and each is removed when its component unmounts.

That DOM order decides two things:

  • Which overlay is on top. Overlapping siblings with the same z-index paint in DOM order, so an overlay that mounts second covers the first. That is what you want when one modal opens another.
  • Where a toast appears in a list. Toasts in normal flow are laid out in DOM order, so the newest is at the bottom. To show it at the top instead, give the target display: flex and flex-direction: column-reverse. Only the layout flips; the DOM order stays the same.

A <dialog> opened with showModal() is the exception to the first point. The browser draws it in the top layer, above the rest of the page, and the dialog opened last appears above the others.

Keep the number of targets small. body for modals and one #tooltips container is enough for most apps; a target per component defeats the purpose.

Check it stuck3 questions from the Teleport and Suspense quiz.
Practice now
Was this helpful?

Keep reading