Advanced concepts
Animations
Learn how to add animations to a multi-step form using Motion.
Initial steps
We'll show you how to add animations to a multi-step form using Motion. A pre-built form is available in the GitHub repository below, so go ahead and clone it to follow along.
git clone https://github.com/martiserra99/formity-react-advanced-concepts
Make sure you run the following command to install all the dependencies.
npm install
Additionally, you also need to install Motion by doing the following.
npm install motion
Key prop
If you take a look at schema.tsx
, you'll see that we pass a key
prop to every Step
. To animate form transitions, we need to use the key
inside the MultiStep
component.
We will update the MultiStep
component with the following code.
// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack } from "@formity/react";
import { useMemo } from "react";
import { MultiStepContext } from "./multi-step-context";
interface MultiStepProps {
step: string;
onNext: OnNext;
onBack: OnBack;
children: ReactNode;
}
export function MultiStep({ step, onNext, onBack, children }: MultiStepProps) {
const values = useMemo(() => ({ onNext, onBack }), [onNext, onBack]);
return (
<div key={step} className="h-full">
<MultiStepContext.Provider value={values}>
{children}
</MultiStepContext.Provider>
</div>
);
}
We will update the schema.tsx
file with the following code.
// schema.tsx
import type { Schema, Form, Return, Cond } from "@formity/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Step,
Layout,
Row,
TextField,
NumberField,
YesNo,
MultiSelect,
Listbox,
NextButton,
BackButton,
} from "./components";
import { MultiStep } from "./multi-step";
export type Values = [
Form<{ name: string; surname: string; age: number }>,
Form<{ softwareDeveloper: boolean }>,
Cond<{
then: [
Form<{ languages: string[] }>,
Return<{
name: string;
surname: string;
age: number;
softwareDeveloper: true;
languages: string[];
}>,
];
else: [
Form<{ interested: string }>,
Return<{
name: string;
surname: string;
age: number;
softwareDeveloper: false;
interested: string;
}>,
];
}>,
];
export const schema: Schema<Values> = [
{
form: {
values: () => ({
name: ["", []],
surname: ["", []],
age: [20, []],
}),
render: ({ values, onNext, onBack }) => (
<MultiStep step="main" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
name: z
.string()
.min(1, { message: "Required" })
.max(20, { message: "Must be at most 20 characters" }),
surname: z
.string()
.min(1, { message: "Required" })
.max(20, { message: "Must be at most 20 characters" }),
age: z
.number()
.min(18, { message: "Minimum of 18 years old" })
.max(99, { message: "Maximum of 99 years old" }),
}),
)}
>
<Layout
heading="Tell us about yourself"
description="We would want to know about you"
fields={[
<Row
key="nameSurname"
items={[
<TextField key="name" name="name" label="Name" />,
<TextField key="surname" name="surname" label="Surname" />,
]}
/>,
<NumberField key="age" name="age" label="Age" />,
]}
button={<NextButton>Next</NextButton>}
/>
</Step>
</MultiStep>
),
},
},
{
form: {
values: () => ({
softwareDeveloper: [true, []],
}),
render: ({ values, onNext, onBack }) => (
<MultiStep step="softwareDeveloper" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
softwareDeveloper: z.boolean(),
}),
)}
>
<Layout
heading="Are you a software developer?"
description="We would like to know if you are a software developer"
fields={[
<YesNo
key="softwareDeveloper"
name="softwareDeveloper"
label="Software Developer"
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
),
},
},
{
cond: {
if: ({ softwareDeveloper }) => softwareDeveloper,
then: [
{
form: {
values: () => ({
languages: [[], []],
}),
render: ({ values, onNext, onBack }) => (
<MultiStep step="languages" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
languages: z.array(z.string()),
}),
)}
>
<Layout
heading="What are your favourite programming languages?"
description="We would like to know which of the following programming languages you like the most"
fields={[
<MultiSelect
key="languages"
name="languages"
label="Languages"
options={[
{ value: "javascript", label: "JavaScript" },
{ value: "python", label: "Python" },
{ value: "go", label: "Go" },
]}
direction="y"
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
),
},
},
{
return: ({ name, surname, age, languages }) => ({
name,
surname,
age,
softwareDeveloper: true,
languages,
}),
},
],
else: [
{
form: {
values: () => ({
interested: ["maybe", []],
}),
render: ({ values, onNext, onBack }) => (
<MultiStep step="interested" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
interested: z.string(),
}),
)}
>
<Layout
heading="Would you be interested in learning how to code?"
description="Having coding skills can be very beneficial"
fields={[
<Listbox
key="interested"
name="interested"
label="Interested"
options={[
{
value: "maybe",
label: "Maybe in another time.",
},
{
value: "yes",
label: "Yes, that sounds good.",
},
{
value: "no",
label: "No, it is not for me.",
},
]}
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
),
},
},
{
return: ({ name, surname, age, interested }) => ({
name,
surname,
age,
softwareDeveloper: false,
interested,
}),
},
],
},
},
];
Animation
After that, we need to use motion
and AnimatePresence
to ensure the form transition plays correctly when the key
prop changes.
// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack } from "@formity/react";
import { useMemo } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MultiStepContext } from "./multi-step-context";
interface MultiStepProps {
step: string;
onNext: OnNext;
onBack: OnBack;
children: ReactNode;
}
export function MultiStep({ step, onNext, onBack, children }: MultiStepProps) {
const values = useMemo(() => ({ onNext, onBack }), [onNext, onBack]);
return (
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={step}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { delay: 0.25, duration: 0.25 },
}}
exit={{
opacity: 0,
transition: { delay: 0, duration: 0.25 },
}}
className="h-full"
>
<MultiStepContext.Provider value={values}>
{children}
</MultiStepContext.Provider>
</motion.div>
</AnimatePresence>
);
}
Form controls stay active during transitions. To block interaction, we can disable the form controls or use the inert
prop when the transition is taking place.
// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack } from "@formity/react";
import { useState, useCallback, useMemo } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MultiStepContext } from "./multi-step-context";
interface MultiStepProps {
step: string;
onNext: OnNext;
onBack: OnBack;
children: ReactNode;
}
export function MultiStep({ step, onNext, onBack, children }: MultiStepProps) {
const [animate, setAnimate] = useState<boolean>(false);
const handleNext = useCallback<OnNext>(
(values) => {
setAnimate(true);
onNext(values);
},
[onNext],
);
const handleBack = useCallback<OnBack>(
(values) => {
setAnimate(true);
onBack(values);
},
[onBack],
);
const values = useMemo(
() => ({ onNext: handleNext, onBack: handleBack }),
[handleNext, handleBack],
);
return (
<AnimatePresence
mode="popLayout"
initial={false}
onExitComplete={() => setAnimate(false)}
>
<motion.div
key={step}
inert={animate}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { delay: 0.25, duration: 0.25 },
}}
exit={{
opacity: 0,
transition: { delay: 0, duration: 0.25 },
}}
className="h-full"
>
<MultiStepContext.Provider value={values}>
{children}
</MultiStepContext.Provider>
</motion.div>
</AnimatePresence>
);
}
Since the key
changes alongside the animation state, inert
only applies to the next form. Delaying the navigation ensures the state change also affects the current form.
// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack } from "@formity/react";
import { useState, useCallback, useMemo } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MultiStepContext } from "./multi-step-context";
interface MultiStepProps {
step: string;
onNext: OnNext;
onBack: OnBack;
children: ReactNode;
}
export function MultiStep({ step, onNext, onBack, children }: MultiStepProps) {
const [animate, setAnimate] = useState<boolean>(false);
const handleNext = useCallback<OnNext>(
(values) => {
setAnimate(true);
setTimeout(() => onNext(values), 0);
},
[onNext],
);
const handleBack = useCallback<OnBack>(
(values) => {
setAnimate(true);
setTimeout(() => onBack(values), 0);
},
[onBack],
);
const values = useMemo(
() => ({ onNext: handleNext, onBack: handleBack }),
[handleNext, handleBack],
);
return (
<AnimatePresence
mode="popLayout"
initial={false}
onExitComplete={() => setAnimate(false)}
>
<motion.div
key={step}
inert={animate}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { delay: 0.25, duration: 0.25 },
}}
exit={{
opacity: 0,
transition: { delay: 0, duration: 0.25 },
}}
className="h-full"
>
<MultiStepContext.Provider value={values}>
{children}
</MultiStepContext.Provider>
</motion.div>
</AnimatePresence>
);
}
Animation direction
To play different animations when navigating forward or backward, we need to track the animation direction. We can do this by updating the code as shown below.
// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { MotionProps } from "motion/react";
import type { OnNext, OnBack } from "@formity/react";
import { useState, useCallback, useMemo } from "react";
import { AnimatePresence, motion } from "motion/react";
import { MultiStepContext } from "./multi-step-context";
interface MultiStepProps {
step: string;
onNext: OnNext;
onBack: OnBack;
children: ReactNode;
}
export function MultiStep({ step, onNext, onBack, children }: MultiStepProps) {
const [animate, setAnimate] = useState<"next" | "back" | false>(false);
const handleNext = useCallback<OnNext>(
(values) => {
setAnimate("next");
setTimeout(() => onNext(values), 0);
},
[onNext],
);
const handleBack = useCallback<OnBack>(
(values) => {
setAnimate("back");
setTimeout(() => onBack(values), 0);
},
[onBack],
);
const values = useMemo(
() => ({ onNext: handleNext, onBack: handleBack }),
[handleNext, handleBack],
);
return (
<AnimatePresence
mode="popLayout"
initial={false}
onExitComplete={() => setAnimate(false)}
>
<motion.div
key={step}
inert={Boolean(animate)}
animate={{
x: 0,
opacity: 1,
transition: { delay: 0.25, duration: 0.25 },
}}
{...motionProps(animate)}
className="h-full"
>
<MultiStepContext.Provider value={values}>
{children}
</MultiStepContext.Provider>
</motion.div>
</AnimatePresence>
);
}
function motionProps(animate: "next" | "back" | false): MotionProps {
switch (animate) {
case "next":
return {
initial: { x: 100, opacity: 0 },
exit: {
x: -100,
opacity: 0,
transition: { delay: 0, duration: 0.25 },
},
};
case "back":
return {
initial: { x: -100, opacity: 0 },
exit: {
x: 100,
opacity: 0,
transition: { delay: 0, duration: 0.25 },
},
};
default:
return {};
}
}
Progress bar
We could also add a progress bar that is animated every time we go to a different step. To do it, we will create a components/screen.tsx
file with the following component.
// components/screen.tsx
import type { ReactNode } from "react";
import { motion } from "framer-motion";
interface ScreenProps {
progress: { total: number; current: number };
children: ReactNode;
}
export default function Screen({ progress, children }: ScreenProps) {
return (
<div className="relative h-full w-full">
<Progress total={progress.total} current={progress.current} />
{children}
</div>
);
}
interface ProgressProps {
total: number;
current: number;
}
function Progress({ total, current }: ProgressProps) {
return (
<div className="absolute left-0 right-0 top-0 h-1 bg-indigo-500/50">
<motion.div
className="h-full bg-indigo-500"
animate={{ width: `${(current / total) * 100}%` }}
initial={false}
/>
</div>
);
}
We will need to export this component from the components/index.ts
file.
// components/index.ts
export { default as Screen } from "@/components/screen";
// ...
Finally, we will be able to use this component in the schema.
// schema.tsx
import type { Schema, Form, Return, Cond } from "@formity/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Screen,
Step,
Layout,
Row,
TextField,
NumberField,
YesNo,
MultiSelect,
Listbox,
NextButton,
BackButton,
} from "./components";
import { MultiStep } from "./multi-step";
export type Values = [
Form<{ name: string; surname: string; age: number }>,
Form<{ softwareDeveloper: boolean }>,
Cond<{
then: [
Form<{ languages: string[] }>,
Return<{
name: string;
surname: string;
age: number;
softwareDeveloper: true;
languages: string[];
}>,
];
else: [
Form<{ interested: string }>,
Return<{
name: string;
surname: string;
age: number;
softwareDeveloper: false;
interested: string;
}>,
];
}>,
];
export const schema: Schema<Values> = [
{
form: {
values: () => ({
name: ["", []],
surname: ["", []],
age: [20, []],
}),
render: ({ values, onNext, onBack }) => (
<Screen progress={{ total: 3, current: 1 }}>
<MultiStep step="main" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
name: z
.string()
.min(1, { message: "Required" })
.max(20, { message: "Must be at most 20 characters" }),
surname: z
.string()
.min(1, { message: "Required" })
.max(20, { message: "Must be at most 20 characters" }),
age: z
.number()
.min(18, { message: "Minimum of 18 years old" })
.max(99, { message: "Maximum of 99 years old" }),
}),
)}
>
<Layout
heading="Tell us about yourself"
description="We would want to know about you"
fields={[
<Row
key="nameSurname"
items={[
<TextField key="name" name="name" label="Name" />,
<TextField
key="surname"
name="surname"
label="Surname"
/>,
]}
/>,
<NumberField key="age" name="age" label="Age" />,
]}
button={<NextButton>Next</NextButton>}
/>
</Step>
</MultiStep>
</Screen>
),
},
},
{
form: {
values: () => ({
softwareDeveloper: [true, []],
}),
render: ({ values, onNext, onBack }) => (
<Screen progress={{ total: 3, current: 2 }}>
<MultiStep step="softwareDeveloper" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
softwareDeveloper: z.boolean(),
}),
)}
>
<Layout
heading="Are you a software developer?"
description="We would like to know if you are a software developer"
fields={[
<YesNo
key="softwareDeveloper"
name="softwareDeveloper"
label="Software Developer"
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
</Screen>
),
},
},
{
cond: {
if: ({ softwareDeveloper }) => softwareDeveloper,
then: [
{
form: {
values: () => ({
languages: [[], []],
}),
render: ({ values, onNext, onBack }) => (
<Screen progress={{ total: 3, current: 3 }}>
<MultiStep step="languages" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
languages: z.array(z.string()),
}),
)}
>
<Layout
heading="What are your favourite programming languages?"
description="We would like to know which of the following programming languages you like the most"
fields={[
<MultiSelect
key="languages"
name="languages"
label="Languages"
options={[
{ value: "javascript", label: "JavaScript" },
{ value: "python", label: "Python" },
{ value: "go", label: "Go" },
]}
direction="y"
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
</Screen>
),
},
},
{
return: ({ name, surname, age, languages }) => ({
name,
surname,
age,
softwareDeveloper: true,
languages,
}),
},
],
else: [
{
form: {
values: () => ({
interested: ["maybe", []],
}),
render: ({ values, onNext, onBack }) => (
<Screen progress={{ total: 3, current: 3 }}>
<MultiStep step="interested" onNext={onNext} onBack={onBack}>
<Step
defaultValues={values}
resolver={zodResolver(
z.object({
interested: z.string(),
}),
)}
>
<Layout
heading="Would you be interested in learning how to code?"
description="Having coding skills can be very beneficial"
fields={[
<Listbox
key="interested"
name="interested"
label="Interested"
options={[
{
value: "maybe",
label: "Maybe in another time.",
},
{
value: "yes",
label: "Yes, that sounds good.",
},
{
value: "no",
label: "No, it is not for me.",
},
]}
/>,
]}
button={<NextButton>Next</NextButton>}
back={<BackButton />}
/>
</Step>
</MultiStep>
</Screen>
),
},
},
{
return: ({ name, surname, age, interested }) => ({
name,
surname,
age,
softwareDeveloper: false,
interested,
}),
},
],
},
},
];