Build Multi-Step Forms with Conditional Logic in React Using Formity
If you've ever asked a coding agent to build a multi-step form with conditional logic and animations, you know the results can be... inconsistent. Sure, they can do it, but getting the exact design, animation style, and branching logic you want every single time? That's a different story.
That's where Formity comes in — a React library that gives you a consistent, repeatable pattern for building advanced multi-step forms that evolve based on user input.
Formity
20% offBuild multi-step forms with ease
Expires Mar 31, 2026
Why Formity?
When you establish a clear pattern for building forms, you can hand that pattern to any coding agent (or your future self) and get predictable results. No more wrestling with inconsistent implementations every time you need:
- Multi-step form flows
- Conditional branching based on user answers
- Smooth animations between steps
- Form validation at each step
- Clean submission handling
Formity works seamlessly with popular form libraries like React Hook Form, Formik, or TanStack Form, giving you full control while handling the complex orchestration of multi-step flows.
Getting Started
Let's build a multi-step form with conditional logic from scratch. We'll start with a basic setup and progressively add features.
Project Setup
Clone the starter repo or set up a new React project with your preferred form library. In this tutorial, we'll use React Hook Form with Zod for validation.
npm install formity react-hook-form @hookform/resolvers zodYour First Multi-Step Form
Formity's core component takes two main props:
schema— defines your form steps, validation, and logiconReturn— callback triggered when the form completes
import { Formity } from 'formity'
import { schema } from './schema'
export default function App() {
const [output, setOutput] = useState<Output | null>(null)
const onReturn = (data: Output) => {
setOutput(data)
}
return <Formity schema={schema} onReturn={onReturn} />
}Understanding the Schema
The schema is where the magic happens. It's an array of steps, where each step is a complete form with its own validation and UI.
// schema.tsx
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
export const schema = [
// Step 1: Basic Info
{
form: {
values: () => ({
name: '',
surname: '',
age: ''
}),
render: ({ values, onNext }) => (
<FormStep
defaultValues={values}
resolver={zodResolver(
z.object({
name: z.string().min(1, 'Name is required'),
surname: z.string().min(1, 'Surname is required'),
age: z.string().min(1, 'Age is required')
})
)}
>
<Input name="name" label="What's your name?" />
<Input name="surname" label="What's your surname?" />
<Input name="age" label="How old are you?" />
<Button onClick={onNext}>Continue</Button>
</FormStep>
)
}
},
// Step 2: More questions...
{
form: {
values: () => ({
softwareDeveloper: ''
}),
render: ({ values, onNext }) => (
<FormStep
defaultValues={values}
resolver={zodResolver(
z.object({
softwareDeveloper: z
.string()
.min(1, 'Please select an option')
})
)}
>
<Select
name="softwareDeveloper"
label="Are you a software developer?"
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' }
]}
/>
<Button onClick={onNext}>Continue</Button>
</FormStep>
)
}
},
// Return statement
{
return: ({ name, surname, age, softwareDeveloper }) => ({
name,
surname,
age,
softwareDeveloper
})
}
]Each step has:
values()— function returning default values for that steprender()— receives current values and anonNextfunction to proceed- A resolver for validation (using Zod in this example)
The final return statement collects all values and shapes your output.
Adding Conditional Logic
Here's where Formity really shines. You can branch your form based on user
responses using cond (condition) blocks:
export const schema = [
// Step 1: Basic Info (same as before)
{
form: {
/* ... */
}
},
// Step 2: Are you a software developer?
{
form: {
/* ... */
}
},
// Step 3: Conditional branching
{
cond: {
if: ({ softwareDeveloper }) => softwareDeveloper === 'yes',
then: [
{
form: {
values: () => ({ expertise: '' }),
render: ({ values, onNext }) => (
<FormStep defaultValues={values} resolver={/* ... */}>
<Select
name="expertise"
label="What's your area of expertise?"
options={[
{ value: 'frontend', label: 'Frontend Development' },
{ value: 'backend', label: 'Backend Development' },
{ value: 'fullstack', label: 'Full Stack' }
]}
/>
<Button onClick={onNext}>Continue</Button>
</FormStep>
)
}
},
{
return: ({ name, surname, expertise }) => ({
name,
surname,
role: 'developer',
expertise
})
}
],
else: [
{
form: {
values: () => ({ interest: '' }),
render: ({ values, onNext }) => (
<FormStep defaultValues={values} resolver={/* ... */}>
<Select
name="interest"
label="What are you interested in?"
options={[
{ value: 'learning', label: 'Learning to code' },
{ value: 'hiring', label: 'Hiring developers' },
{ value: 'other', label: 'Something else' }
]}
/>
<Button onClick={onNext}>Continue</Button>
</FormStep>
)
}
},
{
return: ({ name, surname, interest }) => ({
name,
surname,
role: 'non-developer',
interest
})
}
]
}
}
]Now your form automatically routes users down different paths based on their answers. Software developers get asked about their expertise, while non-developers get asked about their interests.
Handling Form Submission
For production use, you'll want to track submission state and handle async operations:
// types.ts
export type Status =
| { type: 'idle' }
| { type: 'submitting' }
| { type: 'success' }
| { type: 'error'; message: string }
// App.tsx
export default function App() {
const [status, setStatus] = useState<Status>({ type: 'idle' })
const onReturn = async (data: Output) => {
setStatus({ type: 'submitting' })
try {
// Your async submission logic
await submitForm(data)
setStatus({ type: 'success' })
} catch (error) {
setStatus({ type: 'error', message: 'Something went wrong' })
}
}
if (status.type === 'success') {
return <SuccessMessage onRestart={() => setStatus({ type: 'idle' })} />
}
return (
<Formity
schema={schema}
onReturn={onReturn}
disabled={status.type === 'submitting'}
/>
)
}Adding Animations with Framer Motion
Formity plays nicely with Framer Motion for smooth step transitions. Wrap your form steps with motion components:
import { motion, AnimatePresence } from 'framer-motion'
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 300 : -300,
opacity: 0
}),
center: {
x: 0,
opacity: 1
},
exit: (direction: number) => ({
x: direction < 0 ? 300 : -300,
opacity: 0
})
}
function AnimatedFormStep({ children, direction }) {
return (
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={stepIndex}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</AnimatePresence>
)
}This gives you that polished Typeform-style experience where steps slide in and out smoothly.
Formity UI: Pre-Built Components
If you want to skip building components from scratch, Formity UI provides a complete set of Typeform-style components including:
- Styled inputs with validation feedback
- Progress bars
- Keyboard navigation
- Mobile-responsive layouts
- Pre-built animations
It also includes an AI Form Builder — a visual canvas where you can design your form flow, add conditions and branches, then export the generated code directly into your project.
Key Takeaways
- Consistent patterns beat one-off implementations — Formity gives you a repeatable structure for multi-step forms that works the same way every time
- Conditional logic is first-class — Branching based on user input is built into the schema, not bolted on
- Bring your own form library — Works with React Hook Form, Formik, TanStack Form, or whatever you prefer
- Animation-ready — Integrates smoothly with Framer Motion for polished transitions
Resources
Ready to build forms that don't make you pull your hair out? Give Formity a try and establish a pattern your coding agents (and your team) can rely on.