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.
  • replaceModal to 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 })
}

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.
Copyright © 2026