Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 9, 2026

Vue Teleport: Modals That Escape overflow: hidden

Vue Teleport moves a modal to body so overflow, transform and z-index stop clipping it. Learn defer, disabled, SSR in Nuxt, and what Quasar does for you.
Vue
Nuxt
Quasar
SSR

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.

Why modals get clipped

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.
  • A stacking context (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.

A modal with Vue Teleport

<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.

The three Teleport props

to

A 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.

What Teleport doesn't change

Teleport moves DOM nodes, not components. The teleported content is still a logical child of the component that wrote it:

  • Props and emitted events work as before.
  • provide and inject work, so a teleported form still finds its parent's injected store or form context.
  • Vue Devtools shows the content under its parent.
  • Scoped styles still apply, since the elements keep the component's 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.

Vue Teleport and SSR

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:

  1. If you don't need the content in the server HTML (a modal that starts closed), render the Teleport only after mount.
  2. Don't target 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>

Quasar popups already teleport

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:

  • A menu opened inside a dialog stays inside the dialog. A 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.
  • Popups don't render on the server. On the SSR server, Quasar's portal renders nothing, so a dialog can't cause a teleport hydration mismatch. It appears once the client takes over.
  • Styling teleported Quasar content. CSS scoped to your app's root element doesn't reach the portal nodes. Set 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.

When you still need Teleport in a Quasar app

  • Page-specific actions in the layout header, as in the defer example.
  • A floating element that isn't a menu or dialog: a mini player, a chat widget, a drag preview.
  • Moving content into a third-party container that lives outside your app.

Common questions about Vue Teleport

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>.

Next step

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.