Nested flows / wizards
Nested flows / wizards
A common product pattern: a dialog that walks the user through several steps before submitting. This recipe shows two approaches:
- Replace — each step replaces the previous with
replaceModal + instantEnter: true. One modal instance at a time. Best for linear wizards. - 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
import type { DefineGroups } from '@kolirt/vue-modal'
declare module '@kolirt/vue-modal' {
interface ModalGroupRegistry extends DefineGroups<['wizard']> {}
}
<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>
<script setup lang="ts">
import { ref } from 'vue'
import {
ModalRoot,
ModalContent,
ModalTitle,
replaceModal,
useModalContext
} from '@kolirt/vue-modal'
import WizardStep1 from './WizardStep1.vue'
import WizardStep3 from './WizardStep3.vue'
defineOptions({ modalGroup: 'wizard' })
export interface WizardStep2Props { name: string }
const props = defineProps<WizardStep2Props>()
const { close } = useModalContext()
const email = ref('')
function back() {
replaceModal(WizardStep1, { props: {}, instantEnter: true })
}
function next() {
if (!email.value.trim()) return
replaceModal(WizardStep3, {
props: { name: props.name, email: email.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 2 — Your email
</ModalTitle>
<p class="text-sm text-gray-500">Hi, {{ props.name }}!</p>
<input
v-model="email"
type="email"
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@example.com"
/>
<div class="flex justify-between">
<button class="text-sm text-gray-500 hover:text-gray-700" @click="back">← Back</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="!email.trim()"
@click="next"
>
Next →
</button>
</div>
</ModalContent>
</ModalRoot>
</template>
<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' })
export interface WizardResult { name: string; email: string; plan: string }
const props = defineProps<{ name: string; email: string }>()
const { confirm } = useModalContext<WizardResult>()
const plan = ref<'free' | 'pro'>('free')
const submitting = ref(false)
function back() {
replaceModal(WizardStep2, {
props: { name: props.name },
instantEnter: true
})
}
async function submit() {
submitting.value = true
await api.onboarding.complete({ name: props.name, email: props.email, plan: plan.value })
confirm({ name: props.name, email: props.email, plan: plan.value })
}
</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 3 — Pick a plan</ModalTitle>
<div class="flex gap-3">
<label
v-for="p in ['free', 'pro']"
:key="p"
class="flex-1 flex items-center gap-2 rounded-xl border-2 p-4 cursor-pointer transition"
:class="plan === p ? 'border-indigo-600 bg-indigo-50' : 'border-gray-200'"
>
<input v-model="plan" type="radio" :value="p" class="sr-only" />
<span class="font-semibold capitalize text-sm">{{ p }}</span>
</label>
</div>
<div class="flex justify-between">
<button class="text-sm text-gray-500 hover:text-gray-700" @click="back">← Back</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="submitting"
@click="submit"
>
Finish
</button>
</div>
</ModalContent>
</ModalRoot>
</template>
Caller code is in Pattern A below —
openModal(WizardStep1)alone won't deliver the final result, becausereplaceModalrejects intermediate step promises.
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.
<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>
<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>
<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
| Replace | Stack | |
|---|---|---|
| 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 steps | Better | Worse |
Related
Command palette
A Cmd+K command palette built as a fullscreen modal with fuzzy search, using a dedicated modal group and useModal for clean open/close state.
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.
