Recipes
Global error modal
Wire a fetch/axios interceptor to open an error modal from anywhere. Uses a dedicated high-z-index group, replaceModal to deduplicate, and closeModalsByGroup on logout.
Global error modal
Surfaces API errors in a styled dialog opened from a network interceptor — no component context required.
This recipe covers:
- Opening a modal from non-component code (just import
openModal). - A separate
'errors'group with its own<ModalTarget>rendered above everything else. replaceModalto deduplicate — only one error modal shows at a time.closeModalsByGroup('errors')on logout or route change.
Group registration
main.ts
import { createModal } from '@kolirt/vue-modal'
import type { DefineGroups } from '@kolirt/vue-modal'
declare module '@kolirt/vue-modal' {
interface ModalGroupRegistry extends DefineGroups<['default', 'errors']> {}
}
app.use(createModal())
App.vue
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>
<template>
<RouterView />
<!-- Normal modals -->
<ModalTarget group="default">
<ModalOverlay class="bg-black/40" />
</ModalTarget>
<!-- Error modals — rendered last, highest stacking context -->
<ModalTarget group="errors" class="z-[9999]">
<ModalOverlay class="bg-black/50" />
</ModalTarget>
</template>
The error modal component
ErrorModal.vue
<script setup lang="ts">
import {
ModalRoot,
ModalContent,
ModalTitle,
ModalDescription,
useModalContext
} from '@kolirt/vue-modal'
defineOptions({ modalGroup: 'errors' })
const props = defineProps<{
title?: string
message: string
code?: number | string
}>()
const { close } = useModalContext()
</script>
<template>
<ModalRoot class="flex items-center justify-center p-4">
<ModalContent class="relative w-full max-w-sm rounded-2xl bg-white shadow-2xl p-8 space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 rounded-full bg-red-100 p-2">
<svg class="h-5 w-5 text-red-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
</div>
<div>
<ModalTitle class="text-base font-semibold text-gray-900">
{{ props.title ?? 'Something went wrong' }}
</ModalTitle>
<ModalDescription class="mt-1 text-sm text-gray-500">
{{ props.message }}
</ModalDescription>
<p v-if="props.code" class="mt-2 font-mono text-xs text-gray-400">Error {{ props.code }}</p>
</div>
</div>
<div class="flex justify-end">
<button
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
@click="close()"
>
Dismiss
</button>
</div>
</ModalContent>
</ModalRoot>
</template>
The interceptor utility
import { replaceModal, closeModalsByGroup } from '@kolirt/vue-modal'
import ErrorModal from '@/components/ErrorModal.vue'
export interface ApiError {
message: string
title?: string
code?: number | string
}
/**
* Show an error modal. Replaces any existing error modal so only one
* is visible at a time (deduplication via replaceModal).
*/
export function showErrorModal(error: ApiError) {
replaceModal(ErrorModal, {
props: {
message: error.message,
title: error.title,
code: error.code
}
})
}
/**
* Dismiss all error modals — call on logout or route-level error recovery.
*/
export function dismissAllErrors() {
closeModalsByGroup('errors', { ignoreGuard: true, instantExit: true })
}
import axios from 'axios'
import { showErrorModal } from '@/utils/errorModal'
const api = axios.create({ baseURL: '/api' })
api.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status
const data = error.response?.data
// Don't surface 401 — handled by auth redirect
if (status === 401) return Promise.reject(error)
showErrorModal({
title: status ? `Error ${status}` : 'Network error',
message:
data?.message ||
error.message ||
'An unexpected error occurred. Please try again.',
code: data?.code
})
return Promise.reject(error)
}
)
export { api }
import { showErrorModal } from '@/utils/errorModal'
export async function apiFetch<T>(
input: RequestInfo,
init?: RequestInit
): Promise<T> {
const res = await fetch(input, init)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
showErrorModal({
title: `Error ${res.status}`,
message: body?.message ?? res.statusText,
code: body?.code
})
throw new Error(body?.message ?? res.statusText)
}
return res.json() as Promise<T>
}
Clearing errors on route change
router.ts
import { createRouter } from 'vue-router'
import { dismissAllErrors } from '@/utils/errorModal'
const router = createRouter({ /* ... */ })
router.beforeEach(() => {
dismissAllErrors()
})
Clearing errors on logout
auth.ts
import { dismissAllErrors } from '@/utils/errorModal'
export async function logout() {
dismissAllErrors()
await api.auth.logout()
router.push('/login')
}
Why replaceModal for deduplication
Without deduplication, rapid API failures stack multiple error modals. replaceModal atomically closes the current topmost 'errors' modal (bypassing guards, no animation) and opens the new one:
replaceModal(ErrorModal, { props: { message } })
// = close last 'errors' modal (instant) + open new one
The user sees exactly one error at a time.
Why a separate group
Keeping errors in their own <ModalTarget group="errors"> means:
- Error modals always render above business modals (via DOM order / z-index).
closeModalsByGroup('errors')clears only errors — active wizards or forms behind them are unaffected.- Behavior options (Esc, overlay) can be tuned for errors independently.
openModal / replaceModal can be called from any .ts file — they are plain functions, not composables. No component instance is required.