Overlay z-index stacking with automatic z-index calculation, global top tracking, and auto-cleanup.
Features
- Global reactive registry of active overlays
- Automatic z-index calculation based on selection order
globalTop tracking - is this overlay topmost?
- Manual select/unselect for explicit control
- Auto-cleanup when registered within component setup
- Blocking mode for critical dialogs
Installation
import { createStackPlugin } from '@vuetify/v0'
const app = createApp(App)
app.use(createStackPlugin({
baseZIndex: 2000,
increment: 10,
}))
Basic Usage
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
import { ref, watch } from 'vue'
const stack = useStack()
const isOpen = ref(false)
const ticket = stack.register({
onDismiss: () => { isOpen.value = false },
})
watch(isOpen, v => v ? ticket.select() : ticket.unselect())
</script>
<template>
<div>
<button @click="isOpen = true">Open Dialog</button>
<div v-if="isOpen" :style="{ zIndex: ticket.zIndex.value }">
<p>Dialog content</p>
<button @click="ticket.dismiss()">Close</button>
</div>
<div
v-if="stack.isActive.value"
class="scrim"
:style="{ zIndex: stack.scrimZIndex.value }"
@click="stack.top.value?.dismiss()"
/>
</div>
</template>
API Reference
createStackPlugin()
Creates a stack plugin.
Plugin configurationBase z-index when stack is empty. First overlay receives this z-index.
Z-index increment between overlaysGap allows room for overlay-internal elements (dropdowns, tooltips within dialogs).
namespace
string
default:"'v0:stack'"
The namespace for the stack context
StackContext
Number of registered overlays
Whether any overlays are selected (active)Use for conditional scrim rendering.
top
Readonly<Ref<StackTicket | undefined>>
The topmost selected overlay ticketAccess to the current top overlay for inspection.
Z-index for the scrim (one below top overlay)Position scrim behind the topmost overlay but above all others.
Whether the topmost overlay blocks scrim clicksWhen true, the topmost overlay has blocking: true and scrim clicks should be ignored.
register
(input?: Partial<StackTicketInput>) => StackTicket
Register an overlay ticketWhen called within component setup, the ticket is automatically unregistered when the component unmounts.Example:const ticket = stack.register({
onDismiss: () => console.log('dismissed'),
blocking: false,
})
Manually unregister an overlay
StackTicket
Unique identifier for this overlay
The calculated z-index for this overlayAutomatically calculated based on selection order. First selected overlay gets baseZIndex, each subsequent adds increment.
Whether this overlay is the topmost in the global stackUseful for determining which overlay should handle global keyboard events (e.g., Escape key).
Whether this overlay blocks scrim dismissal
Callback invoked when the overlay should be dismissed
Whether this overlay is currently active
Toggle overlay active state
Dismiss this overlayIf blocking is false, calls onDismiss callback and unselects.
If blocking is true, does nothing.
Examples
Modal Dialog
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
import { ref, watch } from 'vue'
const stack = useStack()
const isOpen = ref(false)
const ticket = stack.register({
onDismiss: () => { isOpen.value = false },
})
watch(isOpen, v => v ? ticket.select() : ticket.unselect())
</script>
<template>
<div>
<button @click="isOpen = true">Open Modal</button>
<Teleport to="body">
<div v-if="isOpen" class="modal" :style="{ zIndex: ticket.zIndex.value }">
<div class="modal-content">
<h2>Modal Title</h2>
<p>Modal content</p>
<button @click="ticket.dismiss()">Close</button>
</div>
</div>
</Teleport>
</div>
</template>
Global Scrim
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
const stack = useStack()
const handleScrimClick = () => {
if (!stack.isBlocking.value) {
stack.top.value?.dismiss()
}
}
</script>
<template>
<Teleport to="body">
<div
v-if="stack.isActive.value"
class="scrim"
:style="{ zIndex: stack.scrimZIndex.value }"
@click="handleScrimClick"
/>
</Teleport>
</template>
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
</style>
Blocking Dialog
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
import { ref, watch } from 'vue'
const stack = useStack()
const isOpen = ref(false)
const ticket = stack.register({
blocking: true, // Cannot dismiss with scrim click
onDismiss: () => { isOpen.value = false },
})
watch(isOpen, v => v ? ticket.select() : ticket.unselect())
</script>
<template>
<div>
<button @click="isOpen = true">Open Critical Dialog</button>
<div v-if="isOpen" :style="{ zIndex: ticket.zIndex.value }">
<h2>Confirm Action</h2>
<p>This action cannot be undone.</p>
<button @click="ticket.unselect()">Cancel</button>
<button @click="handleConfirm">Confirm</button>
</div>
</div>
</template>
Multiple Overlays
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
import { ref, watch } from 'vue'
const stack = useStack()
const dialog1Open = ref(false)
const dialog2Open = ref(false)
const ticket1 = stack.register({ onDismiss: () => { dialog1Open.value = false } })
const ticket2 = stack.register({ onDismiss: () => { dialog2Open.value = false } })
watch(dialog1Open, v => v ? ticket1.select() : ticket1.unselect())
watch(dialog2Open, v => v ? ticket2.select() : ticket2.unselect())
</script>
<template>
<div>
<button @click="dialog1Open = true">Open Dialog 1</button>
<button @click="dialog2Open = true">Open Dialog 2</button>
<div v-if="dialog1Open" :style="{ zIndex: ticket1.zIndex.value }">
<p>Dialog 1 (z-index: {{ ticket1.zIndex.value }})</p>
<p v-if="ticket1.globalTop.value">I'm on top!</p>
<button @click="ticket1.dismiss()">Close</button>
</div>
<div v-if="dialog2Open" :style="{ zIndex: ticket2.zIndex.value }">
<p>Dialog 2 (z-index: {{ ticket2.zIndex.value }})</p>
<p v-if="ticket2.globalTop.value">I'm on top!</p>
<button @click="ticket2.dismiss()">Close</button>
</div>
</div>
</template>
Escape Key Handling
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
import { onMounted, onUnmounted, ref, watch } from 'vue'
const stack = useStack()
const isOpen = ref(false)
const ticket = stack.register({
onDismiss: () => { isOpen.value = false },
})
watch(isOpen, v => v ? ticket.select() : ticket.unselect())
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && ticket.globalTop.value) {
ticket.dismiss()
}
}
onMounted(() => {
window.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleEscape)
})
</script>
Auto-Cleanup
Tickets are automatically unregistered when the component unmounts:
<script setup lang="ts">
import { useStack } from '@vuetify/v0'
const stack = useStack()
// Ticket is auto-cleaned up when component unmounts
const ticket = stack.register({
onDismiss: () => { /* ... */ },
})
</script>