Multi-Page Wizard Forms
Long forms kill conversions. But sometimes you need a lot of information. The solution: split forms into pages. Each step feels manageable. Users see progress. And you can validate as they go instead of hitting them with 15 errors at the end.
FormTs handles multi-page forms with addPages(). You get programmatic navigation, progress tracking, and the ability to skip or show pages based on previous answers. No page refreshes, no state management headaches - just smooth step-by-step flows. For hands-on practice, try the interactive tutorial.
Basic Multi-Page Structure
The addPages() method creates a container for multiple pages. Each page is a subform - it can contain rows, fields, and nested subforms just like the root form. Only one page displays at a time.
const pages = form.addPages('wizard');
const personalPage = pages.addPage('personal');
personalPage.addRow(row => {
row.addTextbox('name', { label: 'Full Name', isRequired: true });
});
personalPage.addRow(row => {
row.addEmail('email', { label: 'Email', isRequired: true });
});
const servicePage = pages.addPage('service');
servicePage.addRow(row => {
row.addDropdown('serviceType', {
label: 'Service Type',
options: [
{ id: 'standard', name: 'Standard Cleaning' },
{ id: 'deep', name: 'Deep Cleaning' },
{ id: 'move', name: 'Move-In/Move-Out' }
]
});
});
const confirmPage = pages.addPage('confirm');
confirmPage.addRow(row => {
row.addTextPanel('summary', {
label: () => `Service: ${servicePage.dropdown('serviceType')?.value()}`
});
});This creates three pages: personal info, service selection, and confirmation. The pages render in order, but users only see one at a time. Notice how the confirmation page references fields from the service page - reactive values work across pages.
Navigation Between Pages
Use goToPage() to move between pages. Typically you add Next/Back buttons on each page that trigger navigation.
// Get reference to pages
const pages = form.addPages('wizard');
// Add navigation buttons on each page
const page1 = pages.addPage('step1');
page1.addRow(row => {
row.addTextbox('name', { label: 'Name', isRequired: true });
});
page1.addRow(row => {
row.addButton('next1', {
label: 'Next',
onClick: () => pages.goToPage('step2')
});
});
const page2 = pages.addPage('step2');
page2.addRow(row => {
row.addEmail('email', { label: 'Email', isRequired: true });
});
page2.addRow(row => {
row.addButton('back2', {
label: 'Back',
onClick: () => pages.goToPage('step1')
});
row.addButton('next2', {
label: 'Next',
onClick: () => pages.goToPage('step3')
});
}); The goToPage() method takes the page ID you defined when calling addPage(). Navigation is instant - no page reload, no flicker. The form state persists across all pages.
Pro tip
Put navigation buttons in a consistent position on every page. Users expect Back on the left, Next on the right. Breaking this pattern causes confusion and mis-clicks.
Progress Indicators
Users want to know where they are and how much is left. The currentPageIndex() method returns the zero-based index of the active page. Use it to build progress displays.
// Track current page for progress display
const pages = form.addPages('wizard');
// Add progress indicator at the top (outside pages)
form.addRow(row => {
row.addTextPanel('progress', {
label: () => {
const current = pages.currentPageIndex() + 1;
return `Step ${current} of 3`;
}
});
}); Because currentPageIndex() is reactive, the progress text updates automatically when pages change. You can build more elaborate progress bars using the same approach - the index gives you what you need to calculate percentages or highlight steps.
Progress Bar Patterns
Simple text ("Step 2 of 4") works for short forms. For longer wizards, consider:
- Numbered steps: Shows total scope upfront
- Named steps: "Contact → Service → Payment" helps users understand what's coming
- Progress bar: Visual fill that grows with each step
- Percentage: "50% complete" for longer forms
Validation Before Navigation
Don't let users advance with invalid data. Check the current page before allowing navigation to the next step.
const pages = form.addPages('wizard');
const page1 = pages.addPage('contact');
const nameField = page1.addRow(row => {
row.addTextbox('name', { label: 'Name', isRequired: true });
});
const emailField = page1.addRow(row => {
row.addEmail('email', { label: 'Email', isRequired: true });
});
page1.addRow(row => {
row.addButton('next', {
label: 'Next',
onClick: () => {
// Mark fields as touched to show validation errors
page1.markAllAsTouched();
// Only proceed if page is valid
if (page1.isFormValid()) {
pages.goToPage('details');
}
}
});
}); The markAllAsTouched() method triggers validation display on all fields in the page. Then isFormValid() checks if everything passes. Only proceed if validation succeeds.
This pattern catches errors early. Users fix problems on the page where they occurred, not at the end when they've forgotten the context.
Conditional Pages
Sometimes you need to skip pages based on previous answers. A consultation scheduling page only makes sense if the user wants a consultation. A business details page only matters for business customers.
const pages = form.addPages('wizard');
const servicePage = pages.addPage('service');
servicePage.addRow(row => {
row.addRadioButton('needsConsultation', {
label: 'Do you need a consultation?',
options: [
{ id: 'yes', name: 'Yes, schedule a call' },
{ id: 'no', name: 'No, just send quote' }
]
});
});
// This page only shows if consultation selected
const consultationPage = pages.addPage('consultation');
consultationPage.addRow(row => {
row.addDatepicker('consultDate', {
label: 'Preferred Date',
isVisible: () => servicePage.radioButton('needsConsultation')?.value() === 'yes'
});
row.addTimepicker('consultTime', {
label: 'Preferred Time',
isVisible: () => servicePage.radioButton('needsConsultation')?.value() === 'yes'
});
});
// Skip consultation page if not needed
servicePage.addRow(row => {
row.addButton('next', {
label: 'Next',
onClick: () => {
const needsConsult = servicePage.radioButton('needsConsultation')?.value();
if (needsConsult === 'yes') {
pages.goToPage('consultation');
} else {
pages.goToPage('confirm');
}
}
});
}); The navigation logic in the button's onClick handler checks the user's answer and routes accordingly. Users who don't need a consultation skip straight to confirmation. Users who do see the scheduling page.
Pro tip
When skipping pages, make sure your Back button logic accounts for it. Going back from the confirm page should return to whatever page the user actually saw, not necessarily the previous page in order.
Height Mode: Layout Stability
Pages often have different content heights. When switching between them, should the container resize? FormTs gives you two options.
// Option 1: Fixed height based on tallest page (no layout shift)
const pages = form.addPages('wizard', {
heightMode: 'tallest-page'
});
// Option 2: Dynamic height matching current page
const pages = form.addPages('wizard', {
heightMode: 'current-page'
});tallest-page (default): The container height stays fixed at the height of the tallest page. No layout shift when navigating, but shorter pages may have extra whitespace.
current-page: The container resizes to fit the current page. Tighter layout, but the page may shift when navigating between pages of different heights.
For embedded forms where surrounding content shouldn't jump around, use tallest-page. For full-page wizards where the form is the main content, current-page often looks cleaner.
Complete Example: Quote Wizard
Here's a realistic multi-page form for a cleaning service quote. It collects property info, service preferences, and contact details across three pages, with a calculated price on the final page.
const pages = form.addPages('quoteWizard');
// Page 1: Property Info
const propertyPage = pages.addPage('property');
propertyPage.addRow(row => {
row.addDropdown('propertyType', {
label: 'Property Type',
options: [
{ id: 'house', name: 'House' },
{ id: 'apartment', name: 'Apartment' },
{ id: 'office', name: 'Office' }
],
isRequired: true
});
});
propertyPage.addRow(row => {
row.addSlider('sqft', {
label: 'Square Footage',
min: 500,
max: 5000,
step: 100,
unit: 'sq ft'
});
});
// Page 2: Service Selection
const servicePage = pages.addPage('service');
servicePage.addRow(row => {
row.addRadioButton('serviceType', {
label: 'Service Type',
options: [
{ id: 'standard', name: 'Standard Cleaning' },
{ id: 'deep', name: 'Deep Cleaning' },
{ id: 'move', name: 'Move-In/Move-Out' }
]
});
});
servicePage.addRow(row => {
row.addCheckboxList('extras', {
label: 'Add-ons',
options: [
{ id: 'windows', name: 'Windows (+$30)' },
{ id: 'fridge', name: 'Inside Fridge (+$25)' },
{ id: 'oven', name: 'Inside Oven (+$35)' }
]
});
});
// Page 3: Contact & Quote
const contactPage = pages.addPage('contact');
// Calculate price based on previous pages
const basePrice = form.computedValue(() => {
const sqft = propertyPage.slider('sqft')?.value() ?? 1000;
const serviceType = servicePage.radioButton('serviceType')?.value();
let rate = 0.10; // standard
if (serviceType === 'deep') rate = 0.15;
if (serviceType === 'move') rate = 0.20;
return sqft * rate;
});
const extrasPrice = form.computedValue(() => {
const extras = servicePage.checkboxList('extras')?.value() ?? [];
let total = 0;
if (extras.includes('windows')) total += 30;
if (extras.includes('fridge')) total += 25;
if (extras.includes('oven')) total += 35;
return total;
});
contactPage.addRow(row => {
row.addPriceDisplay('total', {
label: 'Your Quote',
computedValue: () => basePrice() + extrasPrice(),
variant: 'large'
});
});
contactPage.addRow(row => {
row.addTextbox('name', { label: 'Name', isRequired: true });
});
contactPage.addRow(row => {
row.addEmail('email', { label: 'Email', isRequired: true });
});Key patterns in this example:
- Each page focuses on one category of information
- Computed values reference fields across pages
- The final page shows the calculated quote before asking for contact info
- Price updates reactively as users change selections on previous pages
See multi-page forms in action.
When to Use Multi-Page Forms
Not every form needs multiple pages. Splitting a 4-field contact form into 4 pages adds friction without benefit. Use wizard patterns when:
- More than 6-8 fields: Long single-page forms feel overwhelming
- Logical groupings exist: Contact info, service details, payment - natural sections
- Conditional flows: Different users need different questions
- Progressive commitment: Get basic info first, details later
- Complex calculations: Show results after collecting inputs
When Single-Page Is Better
- Short forms: Under 6 fields usually don't need splitting
- Scannable content: When users need to see everything at once
- Frequent editing: When users often jump between sections
- No logical sections: If fields don't group naturally
Best Practices
Keep Pages Focused
Each page should have one purpose. "Contact Information" is clear. "Contact Information and Service Preferences and Special Requests" defeats the point of splitting.
Show Progress Early
Users should see "Step 1 of 3" immediately. Knowing the scope upfront reduces abandonment - people are more willing to start when they know how much is involved.
Allow Going Back
Always provide a Back button (except on the first page). Users make mistakes, change their minds, or want to review previous answers. Trapping them on a page feels hostile.
Save State
FormTs preserves all field values across page navigation automatically. Users can go back and forth without losing data. If you need persistence across sessions (for long forms users might abandon), consider saving to localStorage on each page change.
Mobile Considerations
Multi-page forms often work better on mobile than long scrolling forms. Each step fits on one screen. But make sure your progress indicator and navigation buttons work well on small screens.
Common Questions
Can I have nested pages inside pages?
No, pages are a single level. But each page is a full subform, so you can add nested subforms within a page for grouping fields. For complex multi-level wizards, consider using conditional visibility within pages instead of deeper nesting.
How do I validate all pages before final submission?
Call isFormValid() on each page in sequence, or call it on the root form which validates everything. The root form's submit button won't work unless all visible required fields across all pages are valid. You can also validate page-by-page as users navigate.
Can users jump directly to a specific page?
Yes, goToPage() can navigate to any page by ID. You could build a clickable progress indicator that lets users jump to any completed step. Just be careful about validation - you might want to prevent jumping ahead to uncompleted pages.
What happens to hidden page content?
Fields on non-visible pages retain their values. When users navigate back, they see their previous answers. Validation rules still apply to hidden pages - a required field on page 2 must be filled before final submission even if the user is currently on page 3.