Recipes

Nested flows / wizards

Build a multi-step wizard using replaceModal + instantEnter so steps swap without full open/close transitions. Compares step-replace vs. stacking.

Nested flows / wizards

A common product pattern: a dialog that walks the user through several steps before submitting. This recipe shows two approaches:

  1. Replace — each step replaces the previous with replaceModal + instantEnter: true. One modal instance at a time. Best for linear wizards.
  2. Stack — each step opens on top of the previous with openModal. Previous steps stay mounted. Best for branching flows where the user might go back.

Group registration

main.ts
import type { DefineGroups } from '@kolirt/vue-modal'

declare module '@kolirt/vue-modal' {
  interface ModalGroupRegistry extends DefineGroups<['wizard']> {}
}
App.vue
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>

<template>
  <RouterView />
  <ModalTarget group="wizard">
    <ModalOverlay class="bg-black/40" />
  </ModalTarget>
</template>

Approach 1 — Replace (linear wizard)

Each step component calls replaceModal(NextStep, { props: { ...collected } }) to advance. Only the final step calls confirm(allData).

<script setup lang="ts">
import { ref } from 'vue'
import {
  ModalRoot,
  ModalContent,
  ModalTitle,
  replaceModal,
  useModalContext
} from '@kolirt/vue-modal'
import WizardStep2 from './WizardStep2.vue'

defineOptions({ modalGroup: 'wizard' })

const { close } = useModalContext()
const name = ref('')

function next() {
  if (!name.value.trim()) return
  // Instant swap to step 2 — no enter/exit animation
  replaceModal(WizardStep2, {
    props: { name: name.value },
    instantEnter: true
  })
}
</script>

<template>
  <ModalRoot class="flex items-center justify-center p-4">
    <ModalContent class="relative w-full max-w-sm rounded-2xl bg-white shadow-xl p-8 space-y-6">
        <ModalTitle class="text-xl font-bold text-gray-900">
          Step 1 — Your name
        </ModalTitle>
        <input
          v-model="name"
          type="text"
          class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
          placeholder="Jane Doe"
          @keydown.enter="next"
        />
        <div class="flex justify-between">
          <button class="text-sm text-gray-500 hover:text-gray-700" @click="close()">Cancel</button>
          <button
            class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-40"
            :disabled="!name.trim()"
            @click="next"
          >
            Next →
          </button>
      </div>
    </ModalContent>
  </ModalRoot>
</template>

How state flows between steps

replaceModal rejects the previous step's promise. The handle returned by openModal(WizardStep1) rejects as soon as Step 2 opens — you cannot chain a single await from the caller through to Step 3.
openModal(Step1)              ← caller awaits this
  → replaceModal(Step2, ...)  ← Step1 promise rejects with ModalClosedError
    → replaceModal(Step3, ...) ← Step2 promise rejects with ModalClosedError
      → confirm({...})        ← resolves Step3's promise only

Two practical patterns to deliver the final payload to the caller:

Pattern A — pass an onComplete callback through props

Each step receives an onComplete prop and forwards it to the next step. The final step calls it before confirm(). Simple, no extra state container needed.

WizardStep1.vue
<script setup lang="ts">
const props = defineProps<{ onComplete: (result: WizardResult) => void }>()

function next() {
  replaceModal(WizardStep2, {
    props: { name: name.value, onComplete: props.onComplete },
    instantEnter: true
  })
}
</script>
WizardStep3.vue (final)
<script setup lang="ts">
const props = defineProps<{ name: string; email: string; onComplete: (r: WizardResult) => void }>()
const { confirm } = useModalContext<WizardResult>()

async function submit() {
  const result = { name: props.name, email: props.email, plan: plan.value }
  props.onComplete(result)
  confirm(result)
}
</script>
OnboardingTrigger.vue
<script setup lang="ts">
import { ModalClosedError, openModal } from '@kolirt/vue-modal'
import WizardStep1 from './WizardStep1.vue'

async function startWizard() {
  let result: WizardResult | null = null
  try {
    await openModal(WizardStep1, {
      props: { onComplete: (r) => (result = r) }
    })
  } catch (e) {
    if (!(e instanceof ModalClosedError)) throw e
  }
  // `result` is non-null only if Step 3 ran `onComplete()`
  if (result) console.log('Onboarding complete', result)
}
</script>

Pattern B — stack instead of replace

If you don't need the visual continuity of a single dialog, stacking works without any callback wiring — see Approach 2 below. Each step's confirm(data) resolves the awaited openModal of that step, so the caller can chain results step-by-step.

Approach 2 — Stacking (branching flows)

When steps are non-linear or the user can meaningfully go back without losing input, stack instead of replace:

// Inside StepA.vue
import { openModal } from '@kolirt/vue-modal'
import StepB from './StepB.vue'

const result = await openModal(StepB, {
  props: { fromStep: 'A' }
})
// StepA stays mounted and visible behind StepB

The underlying modal preserves its local state (scroll position, form values) and the user experiences a layered dialog.

When to choose which

ReplaceStack
Steps share one dismiss action
Each step is visually the same dialog shell
User must be able to see/interact with step N-1
Branching (step 2A vs 2B)
Memory / mount cost of accumulating stepsBetterWorse
Copyright © 2026