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.
Table of Contents
- 1. The core problem of stacked modals
- 2. Teleport as the foundation for every modal layer
- 3. One shared Teleport target instead of several separate roots
- 4. z-index management across multiple layers
- 5. Focus management: a focus trap per modal
- 6. Escape key only for the topmost modal
- 7. Scroll lock across several open modals
- 8. A central modal stack as the shared state solution
- 9. Accessibility and ARIA for stacked modals
- 10. Summary
- 11. FAQ
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.
8. A central modal stack as the shared state solution
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