Vue Teleport for Nested, Stacked Modals Done Right
AI generated
{ }
Vue 3 · Teleport · UI Patterns
Stacked Modals with Teleport
Solving z-index, focus, and the escape key across several open dialogs

Building a single modal with Teleport is a well documented task in Vue. It gets harder once a second modal opens on top of the first, for example a confirmation dialog warning the user before leaving a form. At that point, z-index values, focus management, the escape key, and the body element's scroll lock all need to be coordinated so the modals do not interfere with each other.

15 min read Teleport Modal stack

1. The core problem of stacked modals

A single modal is usually solved in Vue by teleporting its content to the end of body, so it is not constrained by an overflow: hidden or a limited stacking context on some ancestor element. As long as only one modal is open at a time, a fixed z-index and a simple keydown listener for the escape key are entirely sufficient, because there is no competition for attention, keyboard focus, or visibility.

As soon as a second modal opens on top of the first, for instance because an action in the first modal requires confirmation, the task changes fundamentally. Both modals teleport to the same spot in the DOM, both want a high z-index, both want to respond to escape, and in the naive case both independently lock background scrolling. Without central coordination this almost always leads to either the wrong modal reacting to escape, or focus falling into a void once the top modal closes.

2. Teleport as the foundation for every modal layer

As a basic building block, Teleport remains correct as is: every modal, no matter where in the component tree it is triggered, teleports its content to a fixed target, typically an element with the ID modal-root placed right before the closing body tag. That places every modal in the same top level stacking context, regardless of whether the triggering component itself is deeply nested or sits inside a container with its own transform or overflow.

The difference with stacked modals is not in the Teleport mechanics themselves, but in the fact that several Teleport calls can be active at once and need to land in the order they were opened. Vue appends teleported content in the order the respective components mount, so a modal opened later actually ends up after an earlier opened modal in the DOM, which is an important foundation for the z-index calculation that follows.


<!-- BaseModal.vue: a single modal layer -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useModalStack } from '@/composables/useModalStack'

const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const { register, unregister, isTopmost, zIndexFor } = useModalStack()

const id = Symbol('modal')

onMounted(() => props.modelValue && register(id))
onUnmounted(() => unregister(id))

function close() {
  emit('update:modelValue', false)
  unregister(id)
}
</script>

<template>
  <Teleport to="#modal-root">
    <div
      v-if="modelValue"
      class="modal-overlay"
      :style="{ zIndex: zIndexFor(id) }"
      @keydown.escape="isTopmost(id) && close()"
    >
      <div class="modal-panel" role="dialog" aria-modal="true">
        <slot />
      </div>
    </div>
  </Teleport>
</template>

3. One shared Teleport target instead of several separate roots

A common beginner mistake is defining a separate Teleport target for every modal, for instance modal-root-1 and modal-root-2, hoping that would make the stacking order easier to control. The opposite is true: multiple separate roots make the DOM order depend on where the root elements sit in the HTML, not on the actual order in which the modals were opened, which quickly leads to inconsistent behavior with dynamically opened dialogs.

A single shared target that all modals teleport to is far more robust, combined with a central modal stack that tracks the real opening order as a data structure. Each modal's z-index is then not hard coded in CSS but computed dynamically from its position in the stack, so a modal opened later automatically sits above every already open modal, regardless of how many there currently are.

4. z-index management across multiple layers

Instead of hard coding a fixed z-index per modal type in CSS, a good approach is a base number, say 1000, to which the modal's position in the stack multiplied by a fixed step is added. The first open modal gets 1000, the second 1010, the third 1020, and each modal's overlay background gets that same value minus one, so it sits between the modal underneath and its own panel. This calculation lives centrally inside the useModalStack logic and never needs to be duplicated in any individual modal component.

It is important to choose a large enough gap between steps to leave room for further overlays opened within a modal, such as dropdowns or tooltips, which typically need a slightly higher z-index than their own modal panel. A gap of ten units per modal level has proven sufficient in practice without having to resort to extremely high, hard to reason about number ranges.

5. Focus management: a focus trap per modal

Every open modal should keep keyboard focus within its own panel, so a tab press does not accidentally jump to elements in the modal behind it or in the page underneath. When a new, higher modal opens, the focus trap of the modal beneath it is paused rather than removed, and when the top modal closes, that exact paused trap is reactivated, so focus reliably returns to where it was before the top modal opened.

The moment of closing itself matters most: when a modal is removed via escape or a close button click, focus should be explicitly set to the element that originally opened the modal, or alternatively to the first focusable element of the now topmost remaining modal. If focus is instead left hanging on an element already removed from the DOM, most browsers silently reset it to body, which completely destroys orientation in the dialog stack for keyboard users.

6. Escape key only for the topmost modal

If every modal registers its own global keydown listener for escape, both react simultaneously to the same key press once two modals are open, and in the worst case both dialogs close at once, even though the user only meant to leave the confirmation dialog. The reliable solution is for every modal to check, before reacting to its own escape handler, whether it is actually the topmost one in the modal stack, and to simply ignore the key press otherwise.

This check can be neatly encapsulated inside the useModalStack composable, which exposes an isTopmost(id) function comparing its own ID against the last entry in the stack. That keeps the logic in every individual modal component reduced to a single condition, while the actual stack bookkeeping happens centrally in one place instead of being rebuilt in every component.

7. Scroll lock across several open modals

For a single open modal, scroll lock is simple: set overflow: hidden on the body element and remove it again once the modal closes. With several stacked modals, this exact naive pattern causes a bug that turns out to be surprisingly common in practice: closing the top one of two modals lets its onUnmounted hook remove overflow: hidden again, even though a modal is still open underneath, and the background becomes scrollable immediately.

The correct solution keeps track of the number of currently open modals in a shared reactive counter. As long as that counter is greater than zero, overflow: hidden stays set on body, and only once the last modal closes and the counter drops back to zero is the lock removed. The same counter logic simultaneously fixes the issue where rapidly setting and removing overflow: hidden on quickly opened and closed modals can cause a briefly visible layout jump.

Every problem described so far, z-index calculation, determining the topmost modal, and scroll lock counting, can be bundled into a single, globally provided useModalStack composable that manages a reactive array holding the IDs of all currently open modals. register appends an ID at the end, unregister removes it again, isTopmost compares against the last array entry, and zIndexFor computes the matching value from the position in the array.

Because the composable works off a shared instance provided via app.provide instead of creating a fresh reactive state on every call, every modal component in the application really does share the same stack, no matter where in the component tree it is rendered. That makes the pattern viable for larger applications too, where modals can be opened from entirely different feature modules without those modules needing to know about each other.

9. Accessibility and ARIA for stacked modals

For screen reader users, the content beneath a newly opened top modal should be marked with aria-hidden true, so screen readers do not accidentally navigate back and forth between two overlapping dialogs. This marking must be correctly reset once the top modal closes, which works most reliably in practice when this state is also derived from the position in the central modal stack rather than maintained separately in every component.

Every modal panel should also carry role dialog, aria-modal true, and an aria-labelledby reference to its own heading, regardless of whether it is the topmost modal or not. These static ARIA attributes stay the same throughout the modal's entire lifecycle, while only the dynamic aspects, such as aria-hidden on elements behind it and the focus trap, change with the stack position, which keeps the overall implementation manageable.

Challenge Naive solution Recommended solution Why it matters
z-index Fixed value per modal type in CSS Computed dynamically from position in the modal stack Works for any number of simultaneously open modals
Escape key Every modal reacts on its own Only the topmost modal reacts (isTopmost check) Prevents escape from closing several modals at once
Scroll lock Set overflow hidden on every mount/unmount Shared counter across all open modals Prevents premature unlocking while a background modal is still open
Focus Independent focus trap per modal Pause the lower modal's trap, reactivate it on close Focus reliably returns to the correct place
Teleport target A separate root element per modal One shared target for all modals DOM order matches the real opening order

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

Stacked Vue modals with Teleport at a glance

Basic mechanism

One shared Teleport target for every modal layer

z-index

Computed dynamically from position in the central modal stack

Escape key

Only the topmost modal reacts, checked via isTopmost

Scroll lock

Shared counter instead of a lock per individual modal

11. FAQ: Stacked Vue modals with Teleport at a glance

1Why isn't a fixed z-index value enough for stacked modals?
A fixed value has no knowledge of how many modals are currently open or in what order. Once a third modal opens on top of two already open ones, an even higher hard coded value would be needed, which does not scale for a dynamic number of modals.
2How do I stop escape from closing two modals at once?
Every modal checks, before reacting itself, whether it is the topmost one in the central modal stack, for instance via an isTopmost function, and ignores the key press entirely otherwise.
3Do I need a separate Teleport target for every modal?
No, quite the opposite. A single shared target that all modals teleport to ensures the DOM order matches the actual opening order, which is essential for the z-index calculation.
4How do I fix the scroll lock bug when the top modal closes?
Instead of setting and removing overflow hidden directly in every modal, count the number of open modals in a shared counter. Only once the counter drops to zero is the lock removed.
5What happens to the lower modal's focus trap when a higher one opens?
It is paused rather than removed. When the top modal closes, the paused trap of the modal beneath it is reactivated, so focus reliably returns there.
6Where should focus go when a modal closes?
Ideally to the element that originally opened the modal, or alternatively to the first focusable element of the now topmost remaining modal. Otherwise, most browsers silently reset focus to body.
7How many levels of stacked modals should a UI allow?
From a usability standpoint, more than two levels rarely make sense, since users quickly lose track. Technically, the pattern described here with a central stack works for any number of levels.
8Do I need to set aria-hidden manually on every background element?
It is most reliable to derive this state from the position in the central modal stack rather than maintaining it separately in every modal component, so it resets correctly and automatically on close.
9Does this pattern work with Nuxt and server side rendering?
Yes, as long as the Teleport target element only exists and gets referenced after hydration. In Nuxt, wrapping the modal root in ClientOnly or using an onMounted guard works well for that.
10Why use a Symbol instead of a simple ID for modal registration?
A Symbol is guaranteed to be unique and collision free for the entire runtime of the application, without having to maintain your own counter or UUID logic, which makes registration in the modal stack more robust.