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.
Table of Contents
- 1. Why a single open/closed flag is not enough
- 2. The store as a stack: data structure and API
- 3. The modal component registers with the store
- 4. Practical example: confirmation dialog over a detail modal
- 5. Z-index calculation based on stack position
- 6. Focus trap per modal level
- 7. Focus return when closing the top modal
- 8. Handling the Escape key only for the topmost modal
- 9. Setting the body scroll lock once, with reference counting
- 10. Summary
- 11. FAQ
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);
},
});
});
3. The modal component registers with the store
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.