Modal Stack: Managing Multiple Stacked Dialogs
AI generated
x-data
Alpine
Alpine.js / UI Components
Modal Stack: Managing Multiple Stacked Dialogs
A global store that treats open dialogs as a stack, instead of just a single open/closed flag

As soon as an application needs to show more than one modal at a time, for example a confirmation dialog above an already open detail modal, a simple boolean flag stops being enough. This article shows how a global Alpine store manages a real stack of open dialogs, which z-index and focus rules apply per level, and how focus correctly returns to the triggering element when the top modal closes.

10 min read Modal Stack Focus Management

1. Why a single open/closed flag is not enough

Most modal implementations start with a single reactive value, typically isOpen, that toggles between true and false. That works fine for one isolated modal, but as soon as a second dialog opens from within it, say a safety confirmation before deleting a record, a conflict appears: both modals share the same state, so closing the second dialog accidentally closes the first one too, or the other way around.

The real problem is conceptual. A boolean describes a state with exactly two values, but the actual requirement is an ordered collection of simultaneously open dialogs with a clear order of who opened last and who should disappear first when Escape is pressed. That ordering can only be represented by a data structure that does more than store yes or no, namely a stack.

2. The store as a stack: data structure and API

A central Alpine store represents the stack as a simple array, where each entry references a unique ID plus the element that triggered the dialog. New modals get appended to the end of the array with push, so the topmost, meaning the most recently opened, modal always sits at the end of the list. This simple rule makes it trivial to determine which modal is currently active and which one global keyboard events should apply to.

The store's API stays deliberately lean: open(id, triggerEl) creates a new entry, close(id) removes a specific entry regardless of its position, and closeTop() removes only the topmost entry. Getters such as isTop(id) and depth(id) give individual modal components the information they need for z-index and focus behavior, without every component having to search the entire stack itself.


// resources/js/stores/modal-stack.js
document.addEventListener('alpine:init', () => {
  Alpine.store('modalStack', {
    stack: [],

    open(id, triggerEl) {
      // Prevent the same modal from being opened twice
      if (this.stack.some((entry) => entry.id === id)) return;
      this.stack.push({ id, triggerEl });
    },

    close(id) {
      this.stack = this.stack.filter((entry) => entry.id !== id);
    },

    closeTop() {
      const top = this.stack.at(-1);
      if (top) this.close(top.id);
    },

    isTop(id) {
      return this.stack.at(-1)?.id === id;
    },

    depth(id) {
      return this.stack.findIndex((entry) => entry.id === id);
    },
  });
});

Every individual modal component only knows its own ID and registers or unregisters with the central store when it opens and closes. It is important that the component itself does not keep its own copy of the open/closed state, and instead exclusively queries the store to check whether its ID is currently present in the stack. That gives you exactly one source of truth, regardless of how many modal instances exist in the DOM.

The x-data object of a modal component stays surprisingly compact as a result. It only exposes computed properties that read from the store, plus the two methods for opening and closing. All the stack logic, ordering, focus and z-index, stays centrally encapsulated in the store and does not need to be reimplemented in every single modal instance.

4. Practical example: confirmation dialog over a detail modal

A common scenario in a storefront backend: a customer opens a detail modal showing the information of a saved address and clicks Delete inside it. Instead of removing the address immediately, a second, smaller confirmation modal opens on top of the first one. Both dialogs stay in the DOM, only the confirmation dialog visually sits above and is interactive above the detail modal, which remains visible in the background but slightly dimmed by an overlay.

The snippet below shows how two modal instances independently use the same store. The detail modal simply opens another modal on delete click without closing itself, and it only reacts to the custom event dispatched by the confirmation dialog once deletion has actually been confirmed.


<div
  x-data="{ id: 'address-detail' }"
  x-show="$store.modalStack.stack.some(e => e.id === id)"
  x-init="$store.modalStack.open(id, $refs.trigger)"
  @close-address-detail.window="$store.modalStack.close(id)"
  class="fixed inset-0 flex items-center justify-center"
  :style="`z-index: ${1000 + $store.modalStack.depth(id)}`"
>
  <div class="bg-white rounded-lg p-6 w-96">
    <h2 class="text-lg font-semibold">Edit Address</h2>
    <button
      @click="$store.modalStack.open('confirm-delete', $event.target)"
      class="mt-4 text-red-600"
    >
      Delete Address
    </button>
  </div>
</div>

<div
  x-data="{ id: 'confirm-delete' }"
  x-show="$store.modalStack.stack.some(e => e.id === id)"
  class="fixed inset-0 flex items-center justify-center"
  :style="`z-index: ${1000 + $store.modalStack.depth(id)}`"
>
  <div class="bg-white rounded-lg p-6 w-80">
    <p>Really delete this address permanently?</p>
    <button @click="$store.modalStack.close(id); $dispatch('close-address-detail')">
      Yes, delete
    </button>
    <button @click="$store.modalStack.close(id)">Cancel</button>
  </div>
</div>

5. Z-index calculation based on stack position

Instead of hardcoding a fixed z-index value for every modal in CSS, each instance calculates its z-index dynamically from its position in the stack. The store returns the array index via depth(id), and the component adds that value to a base number, usually well above the z-index values used elsewhere in the layout, so that even five or six nested levels never collide with other fixed elements.

This approach saves you from having to maintain another, higher z-index value in the stylesheet for every new modal combination. The overlay behind each modal is calculated the same way, one value lower than its own modal, so every level gets its own correctly stacked overlay, without overlays from different levels covering each other incorrectly.

6. Focus trap per modal level

A focus trap keeps the Tab key cycling within a dialog instead of jumping into background elements. With nested modals, this trap must consistently be active only for the topmost modal, all traps below it need to pause in the meantime. Otherwise, pressing Tab inside the confirmation dialog would unexpectedly jump to elements of the detail modal behind it, which is immediately confusing for keyboard users.

In practice, every focus trap instance checks isTop(id) before each Tab event to see whether it currently sits on the topmost level. Only in that case does it actually intervene and prevent leaving the dialog; on every other level it leaves the browser's default behavior untouched, because no element there should be visibly interactive anyway while another modal sits above it.

7. Focus return when closing the top modal

Accessibility does not end at opening a dialog, it also includes correctly returning focus when it closes. When a modal closes, focus must return exactly to the element that originally triggered the dialog, not simply to the body element or the next focusable element in the DOM. That is exactly why the store keeps a reference to the triggering element on every open call.

On close, the store calls triggerEl.focus() for the removed entry right before that modal is taken out of the array. With a modal stack it is particularly important that focus only ever moves back exactly one level: when the user closes the confirmation dialog, focus lands on the Delete button inside the detail modal, not directly on the element that originally opened the detail modal itself.

8. Handling the Escape key only for the topmost modal

A single global Escape listener on window is enough for the entire stack, as long as it consistently removes only the topmost entry. The obvious but wrong implementation would be a separate Escape listener per modal component, because then a single key press would close all open modals at once, since every listener reacts independently to the same event.

The correct solution is a single central listener inside the store itself, which simply calls closeTop() on every Escape event. Since that method always removes only the last entry of the array anyway, the system behaves correctly by construction: one key press closes exactly one level, repeated presses work through the stack from top to bottom until no modal remains open.

9. Setting the body scroll lock once, with reference counting

A common mistake with multiple simultaneously open modals is that each instance independently sets overflow: hidden on the body element when it opens and removes it again when it closes. If the user closes the confirmation dialog first, that component removes the lock even though the detail modal is still open, and the background suddenly scrolls again while a dialog is still visibly active.

The clean solution also lives in the store: it simply counts the length of the stack array. As soon as the first entry is added, the store sets the scroll lock once, and only once the stack is completely empty again does it remove it. Individual modal components no longer need to worry about scroll behavior at all, because that responsibility is fully centralized in the store.

Approach How it works Suited for Weakness
Boolean flag per modal One isOpen value per component, managed independently Exactly one isolated modal without nesting No ordered state when multiple dialogs are open at once
Single global flag One shared open/closed value for the whole app Applications with at most one modal at a time Second modal overwrites or blocks the first
Modal stack in the store Array of ordered entries, topmost entry is active Nested dialogs like confirmation over a detail view Requires slightly more store logic than a plain flag
Native dialog element Browser-native top layer, showModal() per element Single, non-nested dialogs with native focus handling Nested stacking order still needs to be managed manually
Portal-based rendering Modal is teleported to the end of the body Avoiding CSS overflow issues in deeply nested DOM Does not solve the stacking problem itself, only positioning

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Modal Stack in Alpine.js: Key Takeaways

Stack instead of flag

An array in the store replaces the single open/closed flag and correctly represents the actual order of multiple simultaneously open dialogs.

Z-index from position

Each modal's z-index is calculated dynamically from its stack position, instead of maintaining fixed values in the stylesheet.

Only the top trap is active

Only the topmost modal holds keyboard focus, all traps below it pause while another modal remains open.

Focus returns precisely

On close, focus returns exactly to the triggering element, which was stored at the moment the modal opened.

11. FAQ: Modal Stack in Alpine.js: Key Takeaways

1Why does a simple boolean flag for modals stop working once a second dialog appears?
A boolean only knows two states and no ordering. As soon as two modals need to be open at once, such as a confirmation over a detail modal, you need an ordered structure like an array that tracks which modal opened most recently.
2How do you correctly calculate the z-index for multiple stacked modals?
The z-index is calculated from the modal's position in the stack array, usually a base value plus the index. That way every modal automatically gets a higher value than everything below it, without maintaining fixed values in the stylesheet.
3How do you prevent Escape from accidentally closing all open modals at once?
Instead of an Escape listener per modal component, a single central listener in the store is enough, which always removes only the topmost stack entry. That way every key press closes exactly one level.
4Why should focus not simply jump to the body element on close?
Orientation within the document is critical for keyboard users and screen readers. If focus jumps into empty space, the user has to manually search the page again. Focus must therefore return exactly to the triggering element.
5How do you store the triggering element for later focus return?
When calling open(id, triggerEl), the component passes a reference to the element that opened the dialog. The store keeps that reference alongside the ID in the stack entry and retrieves it again on close.
6Why should the focus trap only be active for the topmost modal?
If the trap is active on every level at once, the Tab key jumps unpredictably between different dialogs. Only the topmost, visibly interactive modal should actually keep focus within its own boundaries.
7How do you prevent a modal that closes early from lifting the body scroll lock for other modals?
Instead of every component setting and removing the scroll lock itself, the store simply counts the length of the stack array. The lock is only removed again once the stack is completely empty.
8Can you use the native HTML dialog element instead of a custom store?
For single, non-nested dialogs, showModal() works very well because the browser handles the focus trap and top-layer rendering. An ordered stacking sequence across multiple simultaneously open dialog elements still needs to be managed yourself.
9How many modal levels should you allow in practice at most?
From a usability standpoint, more than two or three levels rarely makes sense because it quickly overwhelms the user. Technically the store itself sets no fixed limit, the pattern scales fine to any number of entries.
10Does every modal component need to know about the entire stack to work correctly?
No. Each component only knows its own ID and specifically queries the information relevant to it through getters like isTop() or depth(), without having to iterate through the entire array itself.