Survey Form in React: Multi-Step, Progress, Scale and Multiple Choice
Build a polished React survey form with multi-step flow, progress bar, Likert scales, and multiple choice — complete with state management and validation.
Why Survey Forms Are Harder Than They Look
You've seen them everywhere — onboarding flows, NPS widgets, checkout questionnaires. They look simple. They're not. A survey form has to manage which step is active, validate only the *current* step's fields, animate between steps without the layout jumping, and keep all answers alive in state until the final submit. That's four separate concerns, and if you don't plan the architecture up front you'll end up with a tangled mess of useState calls and broken back-button behavior.
Honestly, the hardest part isn't the UI — it's the state model. Most tutorials show you a single currentStep integer and call it done. That works for three steps. At step seven with conditional branching, you're debugging at midnight wondering why pressing Back resets an answer the user spent 30 seconds typing.
This guide builds a complete survey form from scratch: multi-step flow, animated progress bar, Likert / rating scales, multiple-choice questions, and a validation pass before each step advances. We'll use React 18 with hooks, TypeScript, and Tailwind CSS. No form library required — though we'll touch on when react-hook-form earns its place.
Worth noting: the patterns here apply whether you're building an NPS modal, a 10-page product feedback form, or a quiz. The shape of the problem is always the same.
Designing the State Model First
Before you write a single JSX tag, nail down the data shape. Every step in a survey maps to a key in your answers object. The step list is just an ordered array of configs. Here's the model that holds up at scale:
// types.ts
export type QuestionType = 'single' | 'multiple' | 'scale' | 'text';
export interface Question {
id: string;
type: QuestionType;
label: string;
options?: string[]; // single / multiple choice
min?: number; // scale
max?: number; // scale
required?: boolean;
}
export interface Step {
id: string;
title: string;
questions: Question[];
}
export type Answers = Record<string, string | string[] | number>;Keep answers flat — keyed by question id, not step index. That way navigating back doesn't destroy data, and submitting is a single Object.values(answers) pass. You also get conditional branching for free: just check answers['q_nps'] before deciding which step comes next.
The hook that ties it together is small. Resist the urge to reach for a state machine library in 2026 — useReducer handles this cleanly:
// useSurvey.ts
import { useReducer } from 'react';
import type { Answers, Step } from './types';
type State = { stepIndex: number; answers: Answers; submitted: boolean };
type Action =
| { type: 'SET_ANSWER'; id: string; value: Answers[string] }
| { type: 'NEXT' }
| { type: 'BACK' }
| { type: 'SUBMIT' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_ANSWER':
return { ...state, answers: { ...state.answers, [action.id]: action.value } };
case 'NEXT':
return { ...state, stepIndex: Math.min(state.stepIndex + 1, 99) };
case 'BACK':
return { ...state, stepIndex: Math.max(state.stepIndex - 1, 0) };
case 'SUBMIT':
return { ...state, submitted: true };
default:
return state;
}
}
export function useSurvey(steps: Step[]) {
const [state, dispatch] = useReducer(reducer, {
stepIndex: 0,
answers: {},
submitted: false,
});
const currentStep = steps[state.stepIndex];
const progress = ((state.stepIndex) / steps.length) * 100;
const validate = () =>
currentStep.questions
.filter((q) => q.required)
.every((q) => {
const val = state.answers[q.id];
if (Array.isArray(val)) return val.length > 0;
return val !== undefined && val !== '';
});
return { state, currentStep, progress, dispatch, validate };
}That's the whole engine. 40 lines. The validate() function only checks required fields on the *active* step, so pressing Next on step 2 won't reject a field you'll fill in on step 4.
Building the Progress Bar
A progress bar does two jobs: it tells users how far they've come, and it reduces drop-off by making the end feel reachable. A segmented bar — one block per step — communicates structure better than a continuous fill. But a smooth animated fill looks more polished. In practice, I'd use the segmented approach for surveys with 5+ steps and the fill bar for shorter flows.
Here's a fill bar that animates via CSS transition — no Framer Motion needed for something this simple:
// ProgressBar.tsx
interface ProgressBarProps {
value: number; // 0–100
steps: number;
current: number;
}
export function ProgressBar({ value, steps, current }: ProgressBarProps) {
return (
<div className="w-full">
<div className="flex justify-between mb-1 text-xs text-gray-400">
<span>Step {current + 1} of {steps}</span>
<span>{Math.round(value)}% complete</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-violet-500 to-fuchsia-500 rounded-full"
style={{
width: `${value}%`,
transition: 'width 400ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
</div>
);
}The gradient (from-violet-500 to-fuchsia-500) is completely optional — swap it for your brand color. The cubic-bezier easing makes the fill feel snappy on advance and smooth on back. Quick aside: setting the width via inline style instead of a Tailwind class is intentional — dynamic percentage values need inline styles unless you're using Tailwind's JIT arbitrary value syntax (w-[67%]), which can't be computed at runtime.
For a segmented version, map over your steps array and apply a filled/active/empty class to each segment. That version pairs nicely with the animated tabs pattern if your survey steps live inside a tabbed container.
Question Types: Multiple Choice, Scale, and Text
Three question types cover 95% of real survey needs. Let's build each as its own focused component that accepts a value, an onChange, and its question config.
Multiple choice (single and multi-select):
// ChoiceQuestion.tsx
interface Props {
question: Question;
value: string | string[];
onChange: (val: string | string[]) => void;
}
export function ChoiceQuestion({ question, value, onChange }: Props) {
const isMulti = question.type === 'multiple';
const selected = Array.isArray(value) ? value : value ? [value] : [];
const toggle = (opt: string) => {
if (!isMulti) { onChange(opt); return; }
const next = selected.includes(opt)
? selected.filter((v) => v !== opt)
: [...selected, opt];
onChange(next);
};
return (
<ul className="space-y-2">
{question.options?.map((opt) => {
const active = selected.includes(opt);
return (
<li key={opt}>
<button
type="button"
onClick={() => toggle(opt)}
className={[
'w-full text-left px-4 py-3 rounded-xl border text-sm font-medium',
'transition-colors duration-150',
active
? 'border-violet-500 bg-violet-50 text-violet-700'
: 'border-gray-200 bg-white text-gray-700 hover:border-violet-300',
].join(' ')}
>
<span className="mr-3">{active ? '●' : '○'}</span>{opt}
</button>
</li>
);
})}
</ul>
);
}Likert / rating scale (1–10 or 1–5): This is the NPS question's home territory. Using a flex row of buttons gives you full keyboard accessibility without hacks:
// ScaleQuestion.tsx
interface Props {
question: Question; // min / max set here
value: number | undefined;
onChange: (val: number) => void;
}
export function ScaleQuestion({ question, value, onChange }: Props) {
const min = question.min ?? 1;
const max = question.max ?? 10;
const points = Array.from({ length: max - min + 1 }, (_, i) => i + min);
return (
<div>
<div className="flex gap-1.5 flex-wrap">
{points.map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={[
'w-10 h-10 rounded-lg text-sm font-semibold border',
'transition-colors duration-150',
value === n
? 'bg-violet-600 border-violet-600 text-white'
: 'bg-white border-gray-200 text-gray-600 hover:border-violet-400',
].join(' ')}
>
{n}
</button>
))}
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Not likely</span><span>Very likely</span>
</div>
</div>
);
}One more thing — the w-10 h-10 (40px) minimum tap target is deliberate. WCAG 2.2 raised the minimum interactive target size to 24x24px, but 40px is the number that actually feels comfortable on mobile. Don't go smaller.
For free-text questions, a <textarea> with a character counter is enough. Keep it simple — resist adding rich text editing to a survey; it's a UX footgun.
Wiring the Multi-Step Shell
The shell component receives your steps config, renders the current step's questions, and connects the navigation buttons to the hook. Adding a slide animation between steps requires a key change on the container — React unmounts and remounts it, which you can intercept with a CSS animation:
// SurveyForm.tsx
import { useState } from 'react';
import { useSurvey } from './useSurvey';
import { ProgressBar } from './ProgressBar';
import { ChoiceQuestion } from './ChoiceQuestion';
import { ScaleQuestion } from './ScaleQuestion';
import type { Step } from './types';
const STEPS: Step[] = [
{
id: 'about',
title: 'About You',
questions: [
{ id: 'role', type: 'single', label: 'What\'s your role?',
options: ['Frontend Dev', 'Full-Stack', 'Designer', 'PM', 'Other'],
required: true },
],
},
{
id: 'experience',
title: 'Your Experience',
questions: [
{ id: 'nps', type: 'scale', label: 'How likely are you to recommend us?',
min: 1, max: 10, required: true },
],
},
{
id: 'features',
title: 'Feature Feedback',
questions: [
{ id: 'features', type: 'multiple',
label: 'Which features do you use most? (select all)',
options: ['Components', 'Templates', 'MCP', 'Cursors', 'Blog'],
required: true },
],
},
];
export function SurveyForm() {
const { state, currentStep, progress, dispatch, validate } = useSurvey(STEPS);
const [error, setError] = useState('');
if (state.submitted) {
return (
<div className="text-center py-16">
<p className="text-2xl font-bold text-gray-900">Thanks! 🎉</p>
<p className="text-gray-500 mt-2">Your response has been recorded.</p>
</div>
);
}
const handleNext = () => {
if (!validate()) { setError('Please answer all required questions.'); return; }
setError('');
const isLast = state.stepIndex === STEPS.length - 1;
if (isLast) dispatch({ type: 'SUBMIT' });
else dispatch({ type: 'NEXT' });
};
return (
<div className="max-w-xl mx-auto p-6 space-y-6">
<ProgressBar value={progress} steps={STEPS.length} current={state.stepIndex} />
<div key={currentStep.id} className="animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-1">{currentStep.title}</h2>
{currentStep.questions.map((q) => (
<div key={q.id} className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{q.label}{q.required && <span className="text-red-400 ml-1">*</span>}
</label>
{(q.type === 'single' || q.type === 'multiple') && (
<ChoiceQuestion
question={q}
value={state.answers[q.id] as string | string[]}
onChange={(val) => dispatch({ type: 'SET_ANSWER', id: q.id, value: val })}
/>
)}
{q.type === 'scale' && (
<ScaleQuestion
question={q}
value={state.answers[q.id] as number | undefined}
onChange={(val) => dispatch({ type: 'SET_ANSWER', id: q.id, value: val })}
/>
)}
</div>
))}
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex justify-between pt-2">
<button
type="button"
onClick={() => dispatch({ type: 'BACK' })}
disabled={state.stepIndex === 0}
className="px-4 py-2 text-sm text-gray-500 disabled:opacity-30"
>
← Back
</button>
<button
type="button"
onClick={handleNext}
className="px-6 py-2.5 bg-violet-600 text-white text-sm font-semibold rounded-xl hover:bg-violet-700 transition-colors"
>
{state.stepIndex === STEPS.length - 1 ? 'Submit' : 'Next →'}
</button>
</div>
</div>
);
}The key={currentStep.id} on the content div is the whole animation trick. Change the key and React re-mounts the element, triggering the animate-fade-in CSS class. Add this to your global CSS or Tailwind config: @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }. Eight pixels of vertical movement is enough to feel like a page turn without being distracting.
Look, you can absolutely reach for Framer Motion here — and if you already have it in your project for other things like page transitions, use it. But for a survey form, the CSS keyframe approach shaves ~50 kB from your bundle.
That said, if your survey has 8+ steps and complex conditional branching, that's when a proper state machine (XState, or even Zustand with a step slice) starts paying for itself. For most product surveys, the useReducer approach above is the right call.
Validation, Accessibility, and Edge Cases
Step-level validation is already in the hook above, but there's one gotcha: what do you do when a required question is a multi-select and the user deselects everything? Your validate() check covers it (val.length > 0), but the UX needs to signal clearly what happened. Showing the error inline — right above the Next button, not in a toast — keeps the user's eyes near the action they need to take.
Accessibility is non-negotiable on forms. Every question needs a real <label> connected to its input via htmlFor / id. For the custom button-based choice and scale components shown above, use role="group" on the container and aria-label on the wrapping element so screen readers announce the question context before reading each option. The 40px button target we set on the scale (w-10 h-10) also satisfies WCAG 2.2's 2.5.8 Target Size criterion, which became a Level AA requirement in 2023.
One edge case people always forget: the browser's autofill. If your survey includes a name or email field, the browser will try to autofill it and potentially trigger onChange events your state didn't expect. Set autoComplete="off" on those inputs, or use explicit autoComplete values (autoComplete="given-name") so the autofill is intentional and controlled.
Worth noting: if you're embedding this survey in a modal or drawer, make sure focus is trapped inside the modal and the first question receives focus on mount. The chat UI in React article covers a reusable focus trap hook that drops in cleanly here.
Styling and Integration with Empire UI
The components above use plain Tailwind. That's intentional — the logic is the hard part and it should be style-agnostic. But if you want a survey form that looks genuinely premium, styling matters a lot. The glassmorphism card style from Empire UI's component library wraps a survey beautifully: a frosted-glass container over a gradient background immediately elevates the perception of your brand.
Wrap the SurveyForm in a GlassCard and drop it on a gradient page background — the same pattern described in the glassmorphism section of the library. The choice buttons already have smooth transition-colors and the violet accent color aligns with Empire UI's default palette. You'd just swap the color tokens to match your brand.
For the visual style of the progress bar gradient, the gradient generator lets you export exact Tailwind class strings in about 30 seconds. And if you want to match your survey's box shadow treatment to the rest of your UI, the box shadow generator exports copy-pasteable CSS.
In practice, the biggest conversion lift on a survey form isn't the animation or the gradient — it's the step count. Every step you cut increases completion rate. If you're designing a 10-step survey, ask yourself: could three of these steps be merged? Show the Step X of Y indicator and watch your analytics; if drop-off spikes on a specific step, that step is too long or too confusing, not your code.
FAQ
Sync your answers state to localStorage via a useEffect that runs on every state change, and rehydrate from localStorage in your initial reducer state. Just clear the key on successful submit.
It works, but it adds complexity for multi-step flows — you'd need useFormContext and per-step schemas. For most surveys, plain useReducer as shown here is simpler and faster to ship.
Replace the flat step array with a getNextStep(answers, currentIndex) function that checks answer values and returns the appropriate next index. Your useSurvey hook dispatches to that function instead of a simple increment.
On SUBMIT dispatch, fire a fetch POST to your API with JSON.stringify(state.answers). Add a loading state to the reducer and disable the Submit button during the request to prevent double submissions.