The State Challenge of Multi-Step Forms

Multi-step forms, often called wizards or steppers, break complex forms into manageable chunks. Instead of overwhelming users with dozens of fields on a single page, wizards guide them through a sequence of focused steps. Each step collects related information, validates it, and only then allows progression to the next step.

Building a wizard presents interesting state management challenges. The wizard needs to track which step the user is on, store all the collected data across steps, validate each step before allowing progression, remember which steps have been visited or completed, and coordinate all of this across multiple components.

Context is the natural solution. The wizard container provides context that all step components can access. Steps can read the shared form data, update their portion of it, and trigger navigation. The context handles validation, tracks progress, and manages the overall wizard state.

In this article, you’ll build a flexible wizard system that you can reuse across your applications. You’ll implement step navigation with validation gates, progress tracking with visual indicators, and a clean API that makes creating new wizards straightforward.


Designing the Wizard

Before implementing, let’s think through what a wizard needs to do and how its API should work.

Core Requirements

A practical wizard must handle these concerns:

ConcernDescription
Step DefinitionEach step needs an identifier, label, and optional validation
Data StorageForm data persists across all steps
NavigationMove forward, backward, or jump to specific steps
ValidationValidate current step before allowing progression
Progress TrackingTrack visited and completed steps
Error DisplayShow validation errors clearly

API Design Goals

The wizard API should be intuitive for developers building forms:

<!-- Desired usage pattern -->
<Wizard {steps} initialData={data} onComplete={handleSubmit}>
	{#if wizard.currentStep.id === 'personal'}
		<PersonalInfoStep />
	{:else if wizard.currentStep.id === 'address'}
		<AddressStep />
	{:else if wizard.currentStep.id === 'payment'}
		<PaymentStep />
	{/if}
</Wizard>

Each step component accesses the wizard context to read and update data:

<!-- Inside a step component -->
<script>
	const wizard = getWizardContext()
</script>

<input value={wizard.data.email} oninput={(e) => wizard.setField('email', e.target.value)} />

This separation keeps step components focused on their specific fields while the wizard handles orchestration.


Type Definitions

Start with comprehensive types that document the wizard’s capabilities:

// src/lib/wizard/types.ts

/**
 * Defines a single step in the wizard.
 */
export interface WizardStep {
	/** Unique identifier for this step */
	id: string

	/** Display label shown in progress indicator */
	label: string

	/** Optional description shown below the label */
	description?: string

	/**
	 * Validation function for this step.
	 * Receives all wizard data and returns validation result.
	 */
	validate?: (data: Record<string, unknown>) => ValidationResult

	/** Whether this step can be skipped */
	optional?: boolean
}

/**
 * Result of validating a step.
 */
export interface ValidationResult {
	/** Whether validation passed */
	valid: boolean

	/** Map of field names to error messages */
	errors: Record<string, string>
}

/**
 * The public API of the wizard context.
 */
export interface WizardContext<T extends Record<string, unknown> = Record<string, unknown>> {
	// Current state
	readonly data: T
	readonly currentStepIndex: number
	readonly currentStep: WizardStep
	readonly steps: readonly WizardStep[]
	readonly errors: Record<string, string>

	// Validation state
	readonly isValid: boolean
	readonly isSubmitting: boolean

	// Navigation state
	readonly canGoBack: boolean
	readonly canGoForward: boolean
	readonly isFirstStep: boolean
	readonly isLastStep: boolean

	// Progress
	readonly progress: number
	readonly visitedSteps: ReadonlySet<number>
	readonly completedSteps: ReadonlySet<number>

	// Data operations
	setField<K extends keyof T>(key: K, value: T[K]): void
	setFields(fields: Partial<T>): void

	// Navigation operations
	nextStep(): boolean
	prevStep(): void
	goToStep(index: number): boolean

	// Validation operations
	validate(): ValidationResult
	setError(field: string, message: string): void
	clearError(field: string): void
	clearAllErrors(): void

	// Wizard operations
	reset(): void
	submit(): Promise<void>
}

/**
 * Configuration for creating a wizard.
 */
export interface WizardOptions<T extends Record<string, unknown>> {
	/** Step definitions */
	steps: WizardStep[]

	/** Initial form data */
	initialData: T

	/** Called when wizard completes successfully */
	onComplete?: (data: T) => void | Promise<void>

	/** Allow jumping to unvisited steps */
	allowSkipAhead?: boolean
}

Understanding the Generic Type

The wizard uses a generic type T for form data. This enables TypeScript to know exactly what fields exist:

interface CheckoutData {
	email: string
	name: string
	address: string
	cardNumber: string
}

// TypeScript knows wizard.data has these exact fields
const wizard = getWizardContext<CheckoutData>()
wizard.setField('email', 'user@example.com') // ✅ Valid
wizard.setField('phone', '555-1234') // ❌ Error: 'phone' not in CheckoutData

Implementing the Wizard Context

The wizard context manages all state and provides the API for step components:

// src/lib/wizard/wizard-context.svelte.ts

import { setContext, getContext, hasContext } from 'svelte'
import type { WizardStep, WizardContext, WizardOptions, ValidationResult } from './types'

/**
 * Symbol key for the wizard context.
 */
const WIZARD_KEY = Symbol('wizard')

/**
 * Creates and provides the wizard context.
 * Call this in your Wizard component.
 */
export function createWizardContext<T extends Record<string, unknown>>(
	options: WizardOptions<T>
): WizardContext<T> {
	const { steps, initialData, onComplete, allowSkipAhead = false } = options

	// Validate that we have at least one step
	if (steps.length === 0) {
		throw new Error('Wizard must have at least one step')
	}

	// ─────────────────────────────────────────────────────────────
	// Core State
	// ─────────────────────────────────────────────────────────────

	// Form data accumulated across all steps
	let data = $state<T>({ ...initialData })

	// Current position in the wizard
	let currentStepIndex = $state(0)

	// Validation errors for the current step
	let errors = $state<Record<string, string>>({})

	// Track which steps have been visited
	let visitedSteps = $state(new Set<number>([0]))

	// Track which steps have been completed (passed validation)
	let completedSteps = $state(new Set<number>())

	// Submission state
	let isSubmitting = $state(false)

	// ─────────────────────────────────────────────────────────────
	// Derived State
	// ─────────────────────────────────────────────────────────────

	// Current step definition
	let currentStep = $derived(steps[currentStepIndex])

	// Position checks
	let isFirstStep = $derived(currentStepIndex === 0)
	let isLastStep = $derived(currentStepIndex === steps.length - 1)

	// Navigation availability
	let canGoBack = $derived(currentStepIndex > 0)

	let canGoForward = $derived.by(() => {
		// Can't go forward from last step
		if (isLastStep) return false

		// If skipping is allowed, always can go forward
		if (allowSkipAhead) return true

		// Otherwise, current step must be completed
		return completedSteps.has(currentStepIndex)
	})

	// Progress percentage (0-100)
	let progress = $derived(Math.round(((currentStepIndex + 1) / steps.length) * 100))

	// Whether current step has no errors
	let isValid = $derived(Object.keys(errors).length === 0)

	// ─────────────────────────────────────────────────────────────
	// Validation
	// ─────────────────────────────────────────────────────────────

	/**
	 * Runs validation for the current step.
	 */
	function runValidation(): ValidationResult {
		const validator = currentStep.validate

		// No validator means step is always valid
		if (!validator) {
			return { valid: true, errors: {} }
		}

		return validator(data as Record<string, unknown>)
	}

	// ─────────────────────────────────────────────────────────────
	// Context Object
	// ─────────────────────────────────────────────────────────────

	const context: WizardContext<T> = {
		// Reactive getters
		get data() {
			return data
		},
		get currentStepIndex() {
			return currentStepIndex
		},
		get currentStep() {
			return currentStep
		},
		get steps() {
			return steps
		},
		get errors() {
			return errors
		},
		get isValid() {
			return isValid
		},
		get isSubmitting() {
			return isSubmitting
		},
		get canGoBack() {
			return canGoBack
		},
		get canGoForward() {
			return canGoForward
		},
		get isFirstStep() {
			return isFirstStep
		},
		get isLastStep() {
			return isLastStep
		},
		get progress() {
			return progress
		},
		get visitedSteps() {
			return visitedSteps
		},
		get completedSteps() {
			return completedSteps
		},

		/**
		 * Updates a single field in the form data.
		 * Automatically clears any error for that field.
		 */
		setField(key, value) {
			data[key] = value

			// Clear error when field is edited
			const fieldKey = key as string
			if (errors[fieldKey]) {
				delete errors[fieldKey]
			}
		},

		/**
		 * Updates multiple fields at once.
		 */
		setFields(fields) {
			Object.assign(data, fields)

			// Clear errors for all changed fields
			for (const key of Object.keys(fields)) {
				if (errors[key]) {
					delete errors[key]
				}
			}
		},

		/**
		 * Validates the current step and updates errors.
		 */
		validate(): ValidationResult {
			const result = runValidation()
			errors = result.errors
			return result
		},

		/**
		 * Attempts to move to the next step.
		 * Validates current step first. Returns false if validation fails.
		 * On the last step, triggers onComplete callback.
		 */
		nextStep(): boolean {
			// Run validation
			const validation = this.validate()

			if (!validation.valid) {
				return false
			}

			// Mark current step as completed
			completedSteps.add(currentStepIndex)

			// If this is the last step, we're done
			if (isLastStep) {
				this.submit()
				return true
			}

			// Move to next step
			currentStepIndex++
			visitedSteps.add(currentStepIndex)

			// Clear errors for the new step
			errors = {}

			return true
		},

		/**
		 * Moves to the previous step.
		 * No validation required to go back.
		 */
		prevStep() {
			if (!canGoBack) return

			currentStepIndex--
			errors = {}
		},

		/**
		 * Jumps to a specific step by index.
		 * Going backward is always allowed.
		 * Going forward requires completed intermediate steps (unless allowSkipAhead).
		 */
		goToStep(index: number): boolean {
			// Validate index bounds
			if (index < 0 || index >= steps.length) {
				return false
			}

			// Same step - no action needed
			if (index === currentStepIndex) {
				return true
			}

			// Going backward is always allowed
			if (index < currentStepIndex) {
				currentStepIndex = index
				errors = {}
				return true
			}

			// Going forward - check permissions
			if (!allowSkipAhead) {
				// Must have completed all steps up to the target
				for (let i = currentStepIndex; i < index; i++) {
					if (!completedSteps.has(i)) {
						return false
					}
				}
			}

			// Jump to the step
			currentStepIndex = index
			visitedSteps.add(index)
			errors = {}

			return true
		},

		/**
		 * Sets a validation error for a specific field.
		 */
		setError(field: string, message: string) {
			errors[field] = message
		},

		/**
		 * Clears the error for a specific field.
		 */
		clearError(field: string) {
			delete errors[field]
		},

		/**
		 * Clears all validation errors.
		 */
		clearAllErrors() {
			errors = {}
		},

		/**
		 * Resets the wizard to its initial state.
		 */
		reset() {
			data = { ...initialData }
			currentStepIndex = 0
			errors = {}
			visitedSteps = new Set([0])
			completedSteps = new Set()
			isSubmitting = false
		},

		/**
		 * Submits the wizard (called when completing the last step).
		 */
		async submit(): Promise<void> {
			if (!onComplete) return

			isSubmitting = true

			try {
				await onComplete(data)
			} finally {
				isSubmitting = false
			}
		}
	}

	return setContext(WIZARD_KEY, context)
}

/**
 * Retrieves the wizard context.
 * Must be called from a component inside a Wizard.
 */
export function getWizardContext<T extends Record<string, unknown>>(): WizardContext<T> {
	if (!hasContext(WIZARD_KEY)) {
		throw new Error('Wizard context not found. ' + 'Ensure this component is inside a Wizard.')
	}
	return getContext(WIZARD_KEY)
}

/**
 * Checks if wizard context is available.
 */
export function hasWizardContext(): boolean {
	return hasContext(WIZARD_KEY)
}

Understanding the Navigation Logic

The wizard’s navigation has intentional constraints:

Going Backward: Always allowed. Users should never feel trapped. Going back preserves all entered data.

Going Forward: Requires the current step to pass validation. This prevents users from skipping required information.

Jumping to Steps: By default, you can only jump to steps you’ve visited or completed. The allowSkipAhead option relaxes this for wizards where step order doesn’t matter.

Error Clearing: When a user edits a field, its error clears immediately. This provides instant feedback that the system recognized their correction.


Building Wizard Components

Now let’s build the UI components that create the wizard experience.

The Wizard Container

The main wrapper that provides context and renders the wizard structure:

<!-- src/lib/wizard/Wizard.svelte -->
<script lang="ts" generics="T extends Record<string, unknown>">
	import { createWizardContext } from './wizard-context.svelte'
	import type { WizardStep } from './types'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Step definitions */
		steps: WizardStep[]

		/** Initial form data */
		initialData: T

		/** Called when wizard completes */
		onComplete?: (data: T) => void | Promise<void>

		/** Allow jumping ahead to unvisited steps */
		allowSkipAhead?: boolean

		/** Child content (step components) */
		children: Snippet
	}

	let { steps, initialData, onComplete, allowSkipAhead = false, children }: Props = $props()

	// Create the wizard context
	const wizard = createWizardContext({
		steps,
		initialData,
		onComplete,
		allowSkipAhead
	})
</script>

<div class="wizard">
	<!-- Progress Bar -->
	<div
		class="wizard-progress"
		role="progressbar"
		aria-valuenow={wizard.progress}
		aria-valuemin="0"
		aria-valuemax="100"
	>
		<div class="progress-fill" style="width: {wizard.progress}%"></div>
	</div>

	<!-- Step Indicators -->
	<nav class="wizard-steps" aria-label="Form progress">
		<ol class="step-list">
			{#each wizard.steps as step, index}
				{@const isActive = index === wizard.currentStepIndex}
				{@const isCompleted = wizard.completedSteps.has(index)}
				{@const isVisited = wizard.visitedSteps.has(index)}
				{@const isClickable = isVisited || allowSkipAhead}

				<li class="step-item">
					<button
						type="button"
						class="step-button"
						class:active={isActive}
						class:completed={isCompleted}
						class:visited={isVisited}
						disabled={!isClickable}
						onclick={() => wizard.goToStep(index)}
						aria-current={isActive ? 'step' : undefined}
					>
						<span class="step-indicator">
							{#if isCompleted}
								<svg
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="3"
									aria-hidden="true"
								>
									<polyline points="20 6 9 17 4 12" />
								</svg>
							{:else}
								{index + 1}
							{/if}
						</span>
						<span class="step-label">{step.label}</span>
					</button>

					{#if index < wizard.steps.length - 1}
						<div class="step-connector" class:completed={isCompleted}></div>
					{/if}
				</li>
			{/each}
		</ol>
	</nav>

	<!-- Current Step Content -->
	<div class="wizard-content">
		<header class="step-header">
			<h2>{wizard.currentStep.label}</h2>
			{#if wizard.currentStep.description}
				<p>{wizard.currentStep.description}</p>
			{/if}
		</header>

		<div class="step-body">
			{@render children()}
		</div>
	</div>

	<!-- Navigation Buttons -->
	<footer class="wizard-footer">
		<button
			type="button"
			class="btn btn-secondary"
			onclick={() => wizard.prevStep()}
			disabled={wizard.isFirstStep || wizard.isSubmitting}
		>
			Back
		</button>

		<button
			type="button"
			class="btn btn-primary"
			onclick={() => wizard.nextStep()}
			disabled={wizard.isSubmitting}
		>
			{#if wizard.isSubmitting}
				<span class="spinner"></span>
				Processing...
			{:else if wizard.isLastStep}
				Complete
			{:else}
				Continue
			{/if}
		</button>
	</footer>
</div>

<style>
	.wizard {
		max-width: 600px;
		margin: 0 auto;
	}

	/* Progress Bar */
	.wizard-progress {
		height: 4px;
		background-color: var(--color-border, #e2e8f0);
		border-radius: 2px;
		overflow: hidden;
		margin-bottom: 2rem;
	}

	.progress-fill {
		height: 100%;
		background-color: var(--color-primary, #3b82f6);
		transition: width 0.3s ease;
	}

	/* Step Indicators */
	.wizard-steps {
		margin-bottom: 2rem;
	}

	.step-list {
		display: flex;
		justify-content: center;
		align-items: flex-start;
		list-style: none;
		padding: 0;
		margin: 0;
	}

	.step-item {
		display: flex;
		align-items: center;
	}

	.step-button {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 0.5rem;
		padding: 0.5rem;
		border: none;
		background: transparent;
		cursor: pointer;
		transition: opacity 0.2s;
	}

	.step-button:disabled {
		cursor: not-allowed;
		opacity: 0.5;
	}

	.step-button:not(:disabled):hover .step-indicator {
		transform: scale(1.1);
	}

	.step-indicator {
		display: flex;
		align-items: center;
		justify-content: center;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		background-color: var(--color-surface, #f1f5f9);
		color: var(--color-foreground-muted, #64748b);
		font-weight: 600;
		font-size: 0.875rem;
		transition: all 0.2s;
	}

	.step-button.active .step-indicator {
		background-color: var(--color-primary, #3b82f6);
		color: white;
	}

	.step-button.completed .step-indicator {
		background-color: var(--color-success, #16a34a);
		color: white;
	}

	.step-indicator svg {
		width: 20px;
		height: 20px;
	}

	.step-label {
		font-size: 0.75rem;
		font-weight: 500;
		color: var(--color-foreground-muted, #64748b);
		white-space: nowrap;
	}

	.step-button.active .step-label {
		color: var(--color-foreground, #1e293b);
	}

	.step-connector {
		width: 40px;
		height: 2px;
		background-color: var(--color-border, #e2e8f0);
		margin: 0 0.5rem;
		margin-bottom: 1.5rem;
		transition: background-color 0.2s;
	}

	.step-connector.completed {
		background-color: var(--color-success, #16a34a);
	}

	/* Content Area */
	.wizard-content {
		background-color: var(--color-surface, #f8fafc);
		border-radius: 12px;
		padding: 2rem;
		margin-bottom: 1.5rem;
	}

	.step-header {
		margin-bottom: 1.5rem;
		text-align: center;
	}

	.step-header h2 {
		margin: 0 0 0.5rem;
		font-size: 1.25rem;
		font-weight: 600;
	}

	.step-header p {
		margin: 0;
		color: var(--color-foreground-muted, #64748b);
		font-size: 0.9375rem;
	}

	.step-body {
		/* Step content styles are handled by step components */
	}

	/* Footer Navigation */
	.wizard-footer {
		display: flex;
		justify-content: space-between;
		gap: 1rem;
	}

	.btn {
		padding: 0.75rem 1.5rem;
		font-size: 0.9375rem;
		font-weight: 500;
		border-radius: 8px;
		cursor: pointer;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		transition: all 0.2s;
	}

	.btn:disabled {
		opacity: 0.6;
		cursor: not-allowed;
	}

	.btn-primary {
		background-color: var(--color-primary, #3b82f6);
		color: white;
		border: none;
	}

	.btn-primary:hover:not(:disabled) {
		background-color: var(--color-primary-hover, #2563eb);
	}

	.btn-secondary {
		background-color: transparent;
		color: var(--color-foreground, #1e293b);
		border: 1px solid var(--color-border, #e2e8f0);
	}

	.btn-secondary:hover:not(:disabled) {
		background-color: var(--color-surface, #f1f5f9);
	}

	.spinner {
		width: 16px;
		height: 16px;
		border: 2px solid rgba(255, 255, 255, 0.3);
		border-top-color: white;
		border-radius: 50%;
		animation: spin 0.8s linear infinite;
	}

	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}

	/* Responsive adjustments */
	@media (max-width: 480px) {
		.step-label {
			display: none;
		}

		.step-connector {
			margin-bottom: 0;
		}
	}
</style>

Form Field Component

A reusable field wrapper that displays labels and errors:

<!-- src/lib/wizard/WizardField.svelte -->
<script lang="ts">
	import { getWizardContext } from './wizard-context.svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Field name (must match a key in wizard data) */
		name: string

		/** Display label */
		label: string

		/** Whether field is required */
		required?: boolean

		/** Help text shown below the input */
		hint?: string

		/** Child content (the input element) */
		children: Snippet
	}

	let { name, label, required = false, hint, children }: Props = $props()

	const wizard = getWizardContext()

	// Check if this field has an error
	let error = $derived(wizard.errors[name])
	let hasError = $derived(!!error)

	// Generate IDs for accessibility
	let inputId = $derived(`wizard-field-${name}`)
	let errorId = $derived(`wizard-error-${name}`)
	let hintId = $derived(`wizard-hint-${name}`)
</script>

<div class="wizard-field" class:has-error={hasError}>
	<label for={inputId} class="field-label">
		{label}
		{#if required}
			<span class="required-mark" aria-hidden="true">*</span>
		{/if}
	</label>

	<div class="field-input">
		{@render children()}
	</div>

	{#if hint && !hasError}
		<p id={hintId} class="field-hint">{hint}</p>
	{/if}

	{#if hasError}
		<p id={errorId} class="field-error" role="alert">{error}</p>
	{/if}
</div>

<style>
	.wizard-field {
		margin-bottom: 1.25rem;
	}

	.field-label {
		display: block;
		margin-bottom: 0.5rem;
		font-size: 0.875rem;
		font-weight: 500;
		color: var(--color-foreground, #1e293b);
	}

	.required-mark {
		color: var(--color-error, #dc2626);
		margin-left: 0.25rem;
	}

	.field-input {
		/* Inputs should fill the container */
	}

	.field-input :global(input),
	.field-input :global(select),
	.field-input :global(textarea) {
		width: 100%;
		padding: 0.75rem 1rem;
		font-size: 0.9375rem;
		border: 1px solid var(--color-border, #e2e8f0);
		border-radius: 8px;
		background-color: white;
		transition:
			border-color 0.2s,
			box-shadow 0.2s;
	}

	.field-input :global(input:focus),
	.field-input :global(select:focus),
	.field-input :global(textarea:focus) {
		outline: none;
		border-color: var(--color-primary, #3b82f6);
		box-shadow: 0 0 0 3px var(--color-primary-ring, rgba(59, 130, 246, 0.2));
	}

	.has-error .field-input :global(input),
	.has-error .field-input :global(select),
	.has-error .field-input :global(textarea) {
		border-color: var(--color-error, #dc2626);
	}

	.has-error .field-input :global(input:focus),
	.has-error .field-input :global(select:focus),
	.has-error .field-input :global(textarea:focus) {
		box-shadow: 0 0 0 3px var(--color-error-ring, rgba(220, 38, 38, 0.2));
	}

	.field-hint {
		margin: 0.5rem 0 0;
		font-size: 0.8125rem;
		color: var(--color-foreground-muted, #64748b);
	}

	.field-error {
		margin: 0.5rem 0 0;
		font-size: 0.8125rem;
		color: var(--color-error, #dc2626);
	}
</style>

Building a Complete Example

Let’s put everything together with a checkout wizard example.

Defining Steps and Validation

First, define the form data structure and step configurations:

// src/routes/checkout/wizard-config.ts

import type { WizardStep } from '$lib/wizard/types'

/**
 * Shape of the checkout form data.
 */
export interface CheckoutData {
	// Personal info
	email: string
	firstName: string
	lastName: string
	phone: string

	// Shipping address
	address: string
	city: string
	state: string
	zipCode: string
	country: string

	// Payment
	cardName: string
	cardNumber: string
	expiry: string
	cvv: string

	// Preferences
	saveInfo: boolean
	newsletter: boolean
}

/**
 * Initial empty state for the form.
 */
export const initialData: CheckoutData = {
	email: '',
	firstName: '',
	lastName: '',
	phone: '',
	address: '',
	city: '',
	state: '',
	zipCode: '',
	country: 'US',
	cardName: '',
	cardNumber: '',
	expiry: '',
	cvv: '',
	saveInfo: false,
	newsletter: false
}

/**
 * Email validation regex.
 */
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

/**
 * Step definitions with validation.
 */
export const steps: WizardStep[] = [
	{
		id: 'personal',
		label: 'Personal Info',
		description: 'Tell us about yourself',
		validate: (data) => {
			const errors: Record<string, string> = {}

			if (!data.email) {
				errors.email = 'Email is required'
			} else if (!emailRegex.test(data.email as string)) {
				errors.email = 'Please enter a valid email'
			}

			if (!data.firstName) {
				errors.firstName = 'First name is required'
			}

			if (!data.lastName) {
				errors.lastName = 'Last name is required'
			}

			return {
				valid: Object.keys(errors).length === 0,
				errors
			}
		}
	},
	{
		id: 'shipping',
		label: 'Shipping',
		description: 'Where should we send your order?',
		validate: (data) => {
			const errors: Record<string, string> = {}

			if (!data.address) {
				errors.address = 'Address is required'
			}

			if (!data.city) {
				errors.city = 'City is required'
			}

			if (!data.state) {
				errors.state = 'State is required'
			}

			if (!data.zipCode) {
				errors.zipCode = 'ZIP code is required'
			} else if (!/^\d{5}(-\d{4})?$/.test(data.zipCode as string)) {
				errors.zipCode = 'Please enter a valid ZIP code'
			}

			return {
				valid: Object.keys(errors).length === 0,
				errors
			}
		}
	},
	{
		id: 'payment',
		label: 'Payment',
		description: 'Enter your payment details',
		validate: (data) => {
			const errors: Record<string, string> = {}

			if (!data.cardName) {
				errors.cardName = 'Name on card is required'
			}

			if (!data.cardNumber) {
				errors.cardNumber = 'Card number is required'
			} else {
				// Basic card number validation (remove spaces)
				const cardNum = (data.cardNumber as string).replace(/\s/g, '')
				if (!/^\d{13,19}$/.test(cardNum)) {
					errors.cardNumber = 'Please enter a valid card number'
				}
			}

			if (!data.expiry) {
				errors.expiry = 'Expiry date is required'
			} else if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(data.expiry as string)) {
				errors.expiry = 'Use MM/YY format'
			}

			if (!data.cvv) {
				errors.cvv = 'CVV is required'
			} else if (!/^\d{3,4}$/.test(data.cvv as string)) {
				errors.cvv = 'CVV must be 3-4 digits'
			}

			return {
				valid: Object.keys(errors).length === 0,
				errors
			}
		}
	},
	{
		id: 'review',
		label: 'Review',
		description: 'Confirm your order details'
		// No validation - just review
	}
]

Step Components

Create individual components for each step:

<!-- src/routes/checkout/PersonalInfoStep.svelte -->
<script lang="ts">
	import { getWizardContext } from '$lib/wizard/wizard-context.svelte'
	import WizardField from '$lib/wizard/WizardField.svelte'
	import type { CheckoutData } from './wizard-config'

	const wizard = getWizardContext<CheckoutData>()
</script>

<div class="step-form">
	<WizardField name="email" label="Email Address" required>
		<input
			type="email"
			id="wizard-field-email"
			value={wizard.data.email}
			oninput={(e) => wizard.setField('email', e.currentTarget.value)}
			placeholder="you@example.com"
			autocomplete="email"
		/>
	</WizardField>

	<div class="field-row">
		<WizardField name="firstName" label="First Name" required>
			<input
				type="text"
				id="wizard-field-firstName"
				value={wizard.data.firstName}
				oninput={(e) => wizard.setField('firstName', e.currentTarget.value)}
				autocomplete="given-name"
			/>
		</WizardField>

		<WizardField name="lastName" label="Last Name" required>
			<input
				type="text"
				id="wizard-field-lastName"
				value={wizard.data.lastName}
				oninput={(e) => wizard.setField('lastName', e.currentTarget.value)}
				autocomplete="family-name"
			/>
		</WizardField>
	</div>

	<WizardField name="phone" label="Phone Number" hint="Optional - for delivery updates">
		<input
			type="tel"
			id="wizard-field-phone"
			value={wizard.data.phone}
			oninput={(e) => wizard.setField('phone', e.currentTarget.value)}
			placeholder="(555) 123-4567"
			autocomplete="tel"
		/>
	</WizardField>
</div>

<style>
	.step-form {
		display: flex;
		flex-direction: column;
	}

	.field-row {
		display: grid;
		grid-template-columns: 1fr 1fr;
		gap: 1rem;
	}

	@media (max-width: 480px) {
		.field-row {
			grid-template-columns: 1fr;
		}
	}
</style>
<!-- src/routes/checkout/ShippingStep.svelte -->
<script lang="ts">
	import { getWizardContext } from '$lib/wizard/wizard-context.svelte'
	import WizardField from '$lib/wizard/WizardField.svelte'
	import type { CheckoutData } from './wizard-config'

	const wizard = getWizardContext<CheckoutData>()

	const states = [
		{ value: '', label: 'Select state' },
		{ value: 'CA', label: 'California' },
		{ value: 'NY', label: 'New York' },
		{ value: 'TX', label: 'Texas' }
		// ... more states
	]
</script>

<div class="step-form">
	<WizardField name="address" label="Street Address" required>
		<input
			type="text"
			id="wizard-field-address"
			value={wizard.data.address}
			oninput={(e) => wizard.setField('address', e.currentTarget.value)}
			placeholder="123 Main St"
			autocomplete="street-address"
		/>
	</WizardField>

	<div class="field-row">
		<WizardField name="city" label="City" required>
			<input
				type="text"
				id="wizard-field-city"
				value={wizard.data.city}
				oninput={(e) => wizard.setField('city', e.currentTarget.value)}
				autocomplete="address-level2"
			/>
		</WizardField>

		<WizardField name="state" label="State" required>
			<select
				id="wizard-field-state"
				value={wizard.data.state}
				onchange={(e) => wizard.setField('state', e.currentTarget.value)}
				autocomplete="address-level1"
			>
				{#each states as state}
					<option value={state.value}>{state.label}</option>
				{/each}
			</select>
		</WizardField>
	</div>

	<div class="field-row">
		<WizardField name="zipCode" label="ZIP Code" required>
			<input
				type="text"
				id="wizard-field-zipCode"
				value={wizard.data.zipCode}
				oninput={(e) => wizard.setField('zipCode', e.currentTarget.value)}
				placeholder="12345"
				autocomplete="postal-code"
			/>
		</WizardField>

		<WizardField name="country" label="Country">
			<select
				id="wizard-field-country"
				value={wizard.data.country}
				onchange={(e) => wizard.setField('country', e.currentTarget.value)}
				autocomplete="country"
			>
				<option value="US">United States</option>
				<option value="CA">Canada</option>
			</select>
		</WizardField>
	</div>
</div>

<style>
	.step-form {
		display: flex;
		flex-direction: column;
	}

	.field-row {
		display: grid;
		grid-template-columns: 1fr 1fr;
		gap: 1rem;
	}

	@media (max-width: 480px) {
		.field-row {
			grid-template-columns: 1fr;
		}
	}
</style>
<!-- src/routes/checkout/ReviewStep.svelte -->
<script lang="ts">
	import { getWizardContext } from '$lib/wizard/wizard-context.svelte'
	import type { CheckoutData } from './wizard-config'

	const wizard = getWizardContext<CheckoutData>()

	// Mask card number for display
	let maskedCard = $derived(
		wizard.data.cardNumber ? '•••• •••• •••• ' + wizard.data.cardNumber.slice(-4) : ''
	)
</script>

<div class="review-step">
	<section class="review-section">
		<header class="section-header">
			<h3>Personal Information</h3>
			<button type="button" class="edit-btn" onclick={() => wizard.goToStep(0)}> Edit </button>
		</header>
		<dl class="review-list">
			<div>
				<dt>Name</dt>
				<dd>{wizard.data.firstName} {wizard.data.lastName}</dd>
			</div>
			<div>
				<dt>Email</dt>
				<dd>{wizard.data.email}</dd>
			</div>
			{#if wizard.data.phone}
				<div>
					<dt>Phone</dt>
					<dd>{wizard.data.phone}</dd>
				</div>
			{/if}
		</dl>
	</section>

	<section class="review-section">
		<header class="section-header">
			<h3>Shipping Address</h3>
			<button type="button" class="edit-btn" onclick={() => wizard.goToStep(1)}> Edit </button>
		</header>
		<address class="shipping-address">
			{wizard.data.address}<br />
			{wizard.data.city}, {wizard.data.state}
			{wizard.data.zipCode}<br />
			{wizard.data.country === 'US' ? 'United States' : 'Canada'}
		</address>
	</section>

	<section class="review-section">
		<header class="section-header">
			<h3>Payment Method</h3>
			<button type="button" class="edit-btn" onclick={() => wizard.goToStep(2)}> Edit </button>
		</header>
		<dl class="review-list">
			<div>
				<dt>Card</dt>
				<dd>{maskedCard}</dd>
			</div>
			<div>
				<dt>Name on Card</dt>
				<dd>{wizard.data.cardName}</dd>
			</div>
		</dl>
	</section>

	<div class="preferences">
		<label class="checkbox-label">
			<input
				type="checkbox"
				checked={wizard.data.saveInfo}
				onchange={(e) => wizard.setField('saveInfo', e.currentTarget.checked)}
			/>
			<span>Save my information for faster checkout next time</span>
		</label>

		<label class="checkbox-label">
			<input
				type="checkbox"
				checked={wizard.data.newsletter}
				onchange={(e) => wizard.setField('newsletter', e.currentTarget.checked)}
			/>
			<span>Subscribe to our newsletter for updates and offers</span>
		</label>
	</div>
</div>

<style>
	.review-step {
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}

	.review-section {
		padding: 1rem;
		background: white;
		border-radius: 8px;
		border: 1px solid var(--color-border, #e2e8f0);
	}

	.section-header {
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 0.75rem;
	}

	.section-header h3 {
		margin: 0;
		font-size: 0.9375rem;
		font-weight: 600;
	}

	.edit-btn {
		padding: 0.25rem 0.75rem;
		font-size: 0.8125rem;
		color: var(--color-primary, #3b82f6);
		background: transparent;
		border: 1px solid var(--color-primary, #3b82f6);
		border-radius: 4px;
		cursor: pointer;
		transition: all 0.2s;
	}

	.edit-btn:hover {
		background: var(--color-primary, #3b82f6);
		color: white;
	}

	.review-list {
		margin: 0;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}

	.review-list > div {
		display: flex;
		gap: 1rem;
	}

	.review-list dt {
		width: 100px;
		color: var(--color-foreground-muted, #64748b);
		font-size: 0.875rem;
	}

	.review-list dd {
		margin: 0;
		font-size: 0.875rem;
	}

	.shipping-address {
		font-style: normal;
		font-size: 0.875rem;
		line-height: 1.6;
	}

	.preferences {
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
		padding-top: 0.5rem;
	}

	.checkbox-label {
		display: flex;
		align-items: flex-start;
		gap: 0.625rem;
		font-size: 0.875rem;
		cursor: pointer;
	}

	.checkbox-label input {
		margin-top: 0.125rem;
	}
</style>

The Checkout Page

Finally, assemble everything in the page:

<!-- src/routes/checkout/+page.svelte -->
<script lang="ts">
	import { goto } from '$app/navigation'
	import Wizard from '$lib/wizard/Wizard.svelte'
	import { getWizardContext } from '$lib/wizard/wizard-context.svelte'
	import { steps, initialData, type CheckoutData } from './wizard-config'

	import PersonalInfoStep from './PersonalInfoStep.svelte'
	import ShippingStep from './ShippingStep.svelte'
	import PaymentStep from './PaymentStep.svelte'
	import ReviewStep from './ReviewStep.svelte'

	/**
	 * Handles form submission when wizard completes.
	 */
	async function handleComplete(data: CheckoutData) {
		// Submit order to your backend
		const response = await fetch('/api/orders', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(data)
		})

		if (!response.ok) {
			throw new Error('Failed to create order')
		}

		const order = await response.json()

		// Redirect to confirmation page
		goto(`/orders/${order.id}/confirmation`)
	}
</script>

<svelte:head>
	<title>Checkout | YourStore</title>
</svelte:head>

<main class="checkout-page">
	<h1>Checkout</h1>

	<Wizard {steps} {initialData} onComplete={handleComplete}>
		{#snippet children()}
			{@const wizard = getWizardContext<CheckoutData>()}

			{#if wizard.currentStep.id === 'personal'}
				<PersonalInfoStep />
			{:else if wizard.currentStep.id === 'shipping'}
				<ShippingStep />
			{:else if wizard.currentStep.id === 'payment'}
				<PaymentStep />
			{:else if wizard.currentStep.id === 'review'}
				<ReviewStep />
			{/if}
		{/snippet}
	</Wizard>
</main>

<style>
	.checkout-page {
		max-width: 700px;
		margin: 0 auto;
		padding: 2rem;
	}

	h1 {
		text-align: center;
		margin-bottom: 2rem;
		font-size: 1.75rem;
	}
</style>

Conclusion

The multi-step form wizard demonstrates context at its most orchestrating. A wizard isn’t just a form broken into pieces—it’s a coordinated system where multiple components share state, validation gates control progression, and progress tracking informs both navigation and user interface. Context provides the shared memory that makes this coordination possible without threading data through component hierarchies.

The generic typing deserves attention. By parameterizing the wizard context with <T extends Record<string, unknown>>, TypeScript knows exactly what fields exist in your form data. When you call wizard.setField('email', value), TypeScript verifies that email exists in your data type. When you access wizard.data.cardNumber, TypeScript knows it’s a string. This compile-time safety catches entire categories of bugs that would otherwise surface at runtime.

What makes this wizard reusable is the separation between orchestration and content. The Wizard component handles navigation, validation gates, progress tracking, and submission—concerns that are the same for every wizard. Step components handle only their specific fields, reading from and writing to the shared context. To create a new wizard, you define your data type, write step configurations with validators, create step components that render fields, and assemble them in the Wizard container. The pattern scales from simple three-step forms to complex branching flows with conditional steps.


Key Takeaways

Building a multi-step form wizard with context demonstrates several important patterns:

Centralized State Management: The wizard context holds all form data, making it accessible to any step component without prop drilling. Each step reads and writes to the same shared state.

Validation Gates: The nextStep function validates the current step before allowing progression. This ensures users provide required information before moving forward.

Derived Navigation State: Properties like canGoBack, canGoForward, and progress are computed from the current state. Components don’t need to calculate these themselves.

Flexible Step Rendering: The wizard container handles navigation and progress while step components focus solely on their fields. This separation makes it easy to add, remove, or reorder steps.

Progress Tracking: The visitedSteps and completedSteps sets enable smart navigation. Users can jump back to completed steps but can’t skip ahead to unvisited ones.

The same pattern works for any multi-step flow: onboarding sequences, registration forms, surveys, configuration wizards, or any process that benefits from discrete steps. Master this pattern, and you’ll handle complex user journeys with ease.


What’s Next

Scale to enterprise complexity in Multi-Tenant SaaS with Context, building a production application with subdomain-based tenants, layered theming, and feature flags.


See Also

Form Resources