How do I build a quiz or lead-capture funnel?
Formhaus describes the funnel as a multi-step JSON definition: radio questions with autoAdvance: true move on as soon as the visitor clicks an answer, routes send each answer to its next question, and a contact step collects the lead. FormRenderer reports every step and field event through onAnalyticsEvent, and lifecycle hooks save the lead before the visitor reaches the last step.
Definition
The plan quiz from examples/react-quiz. Team visitors get an extra question about team size, solo visitors skip it.
json
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"id": "plan-quiz",
"title": "Find your plan",
"submit": { "label": "Book a call" },
"steps": [
{
"id": "audience",
"title": "Who is it for?",
"next": false,
"fields": [{
"key": "audience", "type": "radio", "label": "Who will use it?", "autoAdvance": true,
"helperText": "Click an answer or press Space to continue.",
"validation": { "required": true },
"options": [{ "value": "solo", "label": "Just me" }, { "value": "team", "label": "My team" }]
}],
"routes": [
{ "to": "team", "show": [{ "field": "audience", "eq": "team" }] },
{ "to": "goal" }
]
},
{
"id": "team",
"title": "How big is your team?",
"next": false,
"fields": [{
"key": "teamSize", "type": "radio", "label": "Team size", "autoAdvance": true,
"helperText": "Click an answer or press Space to continue.",
"validation": { "required": true },
"options": [{ "value": "small", "label": "2–10" }, { "value": "medium", "label": "11–50" }, { "value": "large", "label": "51+" }]
}],
"routes": [{ "to": "goal" }]
},
{
"id": "goal",
"title": "What do you need first?",
"next": false,
"fields": [{
"key": "goal", "type": "radio", "label": "Main goal", "autoAdvance": true,
"helperText": "Click an answer or press Space to continue.",
"validation": { "required": true },
"options": [{ "value": "leads", "label": "Collect leads" }, { "value": "surveys", "label": "Run surveys" }, { "value": "onboarding", "label": "Onboard customers" }]
}]
},
{
"id": "contact",
"title": "Where should we send your plan?",
"next": { "label": "See my plan" },
"fields": [
{ "key": "email", "type": "email", "label": "Work email", "validation": { "required": true, "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "patternMessage": "Enter a valid email" } },
{ "key": "consent", "type": "checkbox", "label": "I agree to receive my plan by email", "validation": { "required": "Please confirm to continue" } }
]
},
{
"id": "call",
"title": "Want a walkthrough?",
"fields": [
{ "key": "phone", "type": "phone", "label": "Phone (optional)" }
]
}
]
}React
tsx
import { FormRenderer } from '@formhaus/react';
import type { FormDefinition, StepChangeContext } from '@formhaus/core';
import { useRef } from 'react';
import quiz from './quiz.json';
import { saveLead } from './api';
import { track } from './analytics';
const definition = quiz as FormDefinition;
export function PlanQuiz() {
const leadId = useRef<string | null>(null);
async function captureLead({ fromStepId, direction, values }: StepChangeContext) {
if (fromStepId !== 'contact' || direction !== 'next') return;
leadId.current = await saveLead(leadId.current, values);
}
return (
<FormRenderer
definition={definition}
onBeforeStepChange={captureLead}
onAnalyticsEvent={(event) => track(event.type, event)}
onSubmit={async (values) => {
leadId.current = await saveLead(leadId.current, values);
}}
/>
);
}Vue
vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import type { FormDefinition, StepChangeContext } from '@formhaus/core';
import quiz from './quiz.json';
import { saveLead } from './api';
import { track } from './analytics';
const definition = quiz as FormDefinition;
let leadId: string | null = null;
async function captureLead({ fromStepId, direction, values }: StepChangeContext) {
if (fromStepId !== 'contact' || direction !== 'next') return;
leadId = await saveLead(leadId, values);
}
async function bookCall(values: Record<string, unknown>) {
leadId = await saveLead(leadId, values);
}
</script>
<template>
<FormRenderer
:definition="definition"
:on-before-step-change="captureLead"
:submit-handler="bookCall"
@analytics-event="(event) => track(event.type, event)"
/>
</template>How it works
- Each question step has
"next": falseand one radio with"autoAdvance": true. Clicking an answer or pressing Space or Enter validates the step and moves on. Arrow keys only select. routesonaudiencesendteamto theteamstep and everyone else togoal. Theteamstep routes back togoal, so both paths converge.- Progress counts the active path: four steps for solo visitors, five for teams.
onBeforeStepChangeruns after the contact step validates. Saving the lead there captures the email before the optional phone step. When the save throws, the visitor stays on the step and the error goes toonError, or above the form whenonErroris not set.onSubmitupdates the same lead id, so Back and Continue never create a second lead.onAnalyticsEventemitsstep_viewed,step_completed,step_skipped,field_focused,field_blurred,field_errorandform_submitted. Feed them to your analytics tool to build the funnel report.- The final step never auto-submits, even with
autoAdvanceradios.
Related
- Multi-Step Forms:
autoAdvance,next: falseand lifecycle hooks - Custom Actions & Progress: analytics events
- Multi-step branching: routes with Continue buttons and a skip step
- Examples README: run the quiz locally