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,
          }),
        },
      ],
    },
  },
];