Vue Teleport renders part of a component's template somewhere else in the DOM, usually at the end of <body>, while the component stays where it is in your code. That's the fix for the modal that gets cut off by a card with overflow: hidden, or that sits under a sticky header no matter what z-index you give it. This post covers when you need <Teleport>, its three props, what goes wrong with server-side rendering, and why Quasar's own popups already teleport, so you rarely need it in a Quasar app.
A modal written inline lives inside its parent's box. Three CSS rules on any ancestor can break it:
overflow: hidden (or auto) clips everything outside the ancestor's box, including your overlay.transform, perspective or filter makes the ancestor the containing block for position: fixed. Your "fixed" overlay is now fixed to that card, not to the viewport. The Vue docs call this out directly.z-index on a positioned ancestor, opacity below 1, and others) caps how high a child can go. A z-index: 9999 inside it still loses to a sibling of the ancestor with z-index: 2.You can't fix these from inside the modal's CSS. The modal has to leave the ancestor, and that's what <Teleport> does.
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<div class="card">
<button type="button" @click="open = true">Delete project</button>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="overlay" @click.self="open = false" @keydown.esc="open = false">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this project?</h2>
<button type="button" @click="open = false">Cancel</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.card { overflow: hidden; transform: translateZ(0); }
.overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: rgb(0 0 0 / 0.4); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>The card has both of the rules that usually break a modal, and the overlay still covers the viewport, because its DOM now sits at the end of <body>. Put the v-if inside the <Teleport>, and wrap it in <Transition> if you want it animated.
This is the minimum. A real modal also needs to move focus in when it opens, keep Tab inside it, close on Escape wherever focus is, lock the page scroll, and give focus back to the button when it closes. Teleport solves only the placement.
toA CSS selector or a DOM element. The Vue docs have one hard rule: the target must already be in the DOM when the <Teleport> mounts. Targets outside the Vue app, like body or a <div id="modals"> in index.html, always meet it.
Several Teleports can share a target. Their content is appended in mount order, so a modal opened later lands after earlier ones.
disabled:disabled="true" renders the content in place, as if there were no Teleport. You can toggle it at runtime, which is handy for a panel that floats as an overlay on desktop but sits inline on a phone:
<Teleport to="body" :disabled="isMobile">
<FilterPanel />
</Teleport>defer (Vue 3.5+)defer resolves the target after the rest of the app has mounted, so the target can be an element that Vue itself renders. The most useful case in an app: a page that puts its own buttons into the layout's header.
<!-- layout -->
<q-toolbar>
<q-toolbar-title>Invoices</q-toolbar-title>
<div id="header-actions" class="row q-gutter-sm" />
</q-toolbar>
<!-- page -->
<Teleport to="#header-actions" defer>
<q-btn unelevated no-caps color="primary" label="New invoice" @click="createInvoice" />
</Teleport>Without defer, a hard reload fails: the page and the layout mount together, and #header-actions isn't in the document yet when the Teleport looks for it. The catch, per the Vue docs: the target must render in the same mount or update tick. If it shows up a second later, you still get an error.
Teleport moves DOM nodes, not components. The teleported content is still a logical child of the component that wrote it:
provide and inject work, so a teleported form still finds its parent's injected store or form context.data-v attribute. What breaks are selectors that depend on DOM ancestors, like .dashboard .modal. The modal isn't inside .dashboard anymore.DOM events bubble through the DOM, though. A click inside the teleported modal doesn't reach a native listener on the card it came from.
This is where Teleport bites. When Vue renders on the server, the teleported content is not part of the main HTML string. It's collected separately in the render context's teleports object, keyed by target, and your server code has to inject it into the page. The Vue docs give two pieces of advice:
body with SSR. The body holds other server-rendered markup, so hydration can't find where the teleported part starts. Use a dedicated container that holds only teleported content.Nuxt follows the second rule for you. The Nuxt docs say Nuxt has SSR support for teleports to #teleports only. For any other target, wrap the Teleport in <ClientOnly>:
<!-- Nuxt: rendered on the server, in the #teleports container -->
<Teleport to="#teleports">
<div v-if="open" class="overlay">...</div>
</Teleport>
<!-- Nuxt: any other target, client only -->
<ClientOnly>
<Teleport to="#header-actions" defer>
<q-btn unelevated no-caps color="primary" label="New invoice" />
</Teleport>
</ClientOnly>If you use Quasar, you mostly don't write <Teleport> for overlays. In Quasar 2.35.2, q-dialog, q-menu and q-tooltip render their content through Vue's own Teleport. When one opens, Quasar creates a node at the end of <body> (#q-portal--dialog--1, #q-portal--menu--2 and so on) and teleports into it. Anything built on them inherits this: q-popup-proxy, the q-select options menu, q-btn-dropdown, and the Dialog plugin. Notify, Loading and LoadingBar use their own body-level nodes.
So a q-dialog inside a card with overflow: hidden just works:
<q-card class="overflow-hidden">
<q-card-section>
<q-btn flat no-caps color="negative" label="Delete project" @click="confirm = true" />
</q-card-section>
<q-dialog v-model="confirm">
<q-card style="min-width: 320px">
<q-card-section class="text-h6">Delete this project?</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Cancel" v-close-popup />
<q-btn unelevated no-caps color="negative" label="Delete" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>And it does the parts the hand-made modal above skipped: Escape closes it, focus that leaves the dialog is pulled back in, body scroll is locked, and focus returns to the opener on close.
Three details from the 2.35.2 source and docs:
q-select in a q-dialog form renders its menu within the dialog's element, because screen readers ignore content outside an aria-modal dialog.globalNodes: { class: 'my-app' } in the Quasar config, and Quasar adds that class to every node it creates, so .my-app .q-dialog__inner works.Wrapping a q-dialog in your own <Teleport> gains nothing. For the different ways to open a dialog (the component, the Dialog plugin, a reusable confirm), see the Quasar QDialog guide.
defer example.Does Teleport work with v-show? Yes. The content is teleported once and toggled with display. v-if is the usual choice for modals, so closed ones aren't in the DOM.
Why do I get "Failed to locate Teleport target"? The target isn't in the DOM when the Teleport mounts. Put it outside the app, add defer if Vue renders it in the same tick, or render the Teleport after mount.
Can I teleport to body in Nuxt? It works on the client, but Nuxt's SSR support covers #teleports only. Use #teleports, or wrap the Teleport in <ClientOnly>.
Should I teleport a q-menu or q-tooltip? No. Both already render at the end of <body>.
When the overlay is a confirm box or a short form, start from a finished design. The Dialogs Block has 5 dialog Components built from q-card: an alert, a deactivate confirmation, two success messages and an edit-profile form. One is free. Drop the card into a q-dialog, and the teleporting is already taken care of.