---
title: "Teleport: modals and tooltips that escape their parent · vuejs.school"
description: "Why a modal gets clipped by its parent, how Teleport moves the DOM without moving the component, and how defer, disabled and the native dialog element fit in."
canonical_url: "https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent"
meta:
  "og:description": "Why a modal gets clipped by its parent, how Teleport moves the DOM without moving the component, and how defer, disabled and the native dialog element fit in."
  "og:image": "https://vuejs.school/_og/d/a_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50%2Cdescription_~V2h5IGEgbW9kYWwgZ2V0cyBjbGlwcGVkIGJ5IGl0cyBwYXJlbnQsIGhvdyBUZWxlcG9ydCBtb3ZlcyB0aGUgRE9NIHdpdGhvdXQgbW92aW5nIHRoZSBjb21wb25lbnQsIGFuZCBob3cgZGVmZXIsIGRpc2FibGVkIGFuZCB0aGUgbmF0aXZlIGRpYWxvZyBlbGVtZW50IGZpdCBpbi4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZWxlcG9ydC1tb2RhbHMtdGhhdC1lc2NhcGUtdGhlaXItcGFyZW50Ig%2Cs_38GK1-RREvAZ8tWN.png"
  "og:image:alt": "Teleport: modals and tooltips that escape their parent · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Teleport: modals and tooltips that escape their parent · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50%2Cdescription_~V2h5IGEgbW9kYWwgZ2V0cyBjbGlwcGVkIGJ5IGl0cyBwYXJlbnQsIGhvdyBUZWxlcG9ydCBtb3ZlcyB0aGUgRE9NIHdpdGhvdXQgbW92aW5nIHRoZSBjb21wb25lbnQsIGFuZCBob3cgZGVmZXIsIGRpc2FibGVkIGFuZCB0aGUgbmF0aXZlIGRpYWxvZyBlbGVtZW50IGZpdCBpbi4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZWxlcG9ydC1tb2RhbHMtdGhhdC1lc2NhcGUtdGhlaXItcGFyZW50Ig%2Cs_38GK1-RREvAZ8tWN.png"
  "twitter:image:alt": "Teleport: modals and tooltips that escape their parent · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
<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.vue**Works***

```
<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](https://vuejs.org/guide/built-ins/teleport.html) 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 stuck**3 questions from the Teleport and Suspense quiz.

[**Practice now**](https://vuejs.school/learn/advanced/teleport-and-suspense?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-09","description":"Why a modal gets clipped by its parent, how Teleport moves the DOM without moving the component, and how defer, disabled and the native dialog element fit in.","name":"Teleport: modals and tooltips that escape their parent","url":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent","breadcrumb":{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-10-09","description":"Why a modal gets clipped by its parent, how Teleport moves the DOM without moving the component, and how defer, disabled and the native dialog element fit in.","headline":"Teleport: modals and tooltips that escape their parent","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50%2Cdescription_~V2h5IGEgbW9kYWwgZ2V0cyBjbGlwcGVkIGJ5IGl0cyBwYXJlbnQsIGhvdyBUZWxlcG9ydCBtb3ZlcyB0aGUgRE9NIHdpdGhvdXQgbW92aW5nIHRoZSBjb21wb25lbnQsIGFuZCBob3cgZGVmZXIsIGRpc2FibGVkIGFuZCB0aGUgbmF0aXZlIGRpYWxvZyBlbGVtZW50IGZpdCBpbi4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZWxlcG9ydC1tb2RhbHMtdGhhdC1lc2NhcGUtdGhlaXItcGFyZW50Ig%2Cs_38GK1-RREvAZ8tWN.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/teleport-modals-that-escape-their-parent#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Teleport: modals and tooltips that escape their parent","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50%2Cdescription_~V2h5IGEgbW9kYWwgZ2V0cyBjbGlwcGVkIGJ5IGl0cyBwYXJlbnQsIGhvdyBUZWxlcG9ydCBtb3ZlcyB0aGUgRE9NIHdpdGhvdXQgbW92aW5nIHRoZSBjb21wb25lbnQsIGFuZCBob3cgZGVmZXIsIGRpc2FibGVkIGFuZCB0aGUgbmF0aXZlIGRpYWxvZyBlbGVtZW50IGZpdCBpbi4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZWxlcG9ydC1tb2RhbHMtdGhhdC1lc2NhcGUtdGhlaXItcGFyZW50Ig%2Cs_38GK1-RREvAZ8tWN.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~VGVsZXBvcnQ6IG1vZGFscyBhbmQgdG9vbHRpcHMgdGhhdCBlc2NhcGUgdGhlaXIgcGFyZW50%2Cdescription_~V2h5IGEgbW9kYWwgZ2V0cyBjbGlwcGVkIGJ5IGl0cyBwYXJlbnQsIGhvdyBUZWxlcG9ydCBtb3ZlcyB0aGUgRE9NIHdpdGhvdXQgbW92aW5nIHRoZSBjb21wb25lbnQsIGFuZCBob3cgZGVmZXIsIGRpc2FibGVkIGFuZCB0aGUgbmF0aXZlIGRpYWxvZyBlbGVtZW50IGZpdCBpbi4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDcgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZWxlcG9ydC1tb2RhbHMtdGhhdC1lc2NhcGUtdGhlaXItcGFyZW50Ig%2Cs_38GK1-RREvAZ8tWN.png"}]}
```