Advanced conceptsAnimations

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-animations

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

Animate form

The first thing we'll do is update the FormStatus type to include information about whether we are moving to the next or previous step.

// types/status.ts
export type Status = FormStatus | SubmittedStatus;

export type FormStatus = {
  type: "form";
  move: "next" | "back" | false;
  submitting: boolean;
};

export type SubmittedStatus = {
  type: "submitted";
};

Then, we'll update the Form component so that when we move to the next or previous step, the status changes and the corresponding navigation function is called.

We'll also use the AnimatePresence component and a motion.div so the animation runs whenever the key changes.

// components/form/index.tsx
import type { DefaultValues, Resolver } from "react-hook-form";
import type { OnBack, OnNext } from "@formity/react";
import type { MotionProps } from "motion/react";

import {
  useMemo,
  useState,
  useEffect,
  useCallback,
  useEffectEvent,
} from "react";

import { useForm, FormProvider } from "react-hook-form";
import { motion, AnimatePresence } from "motion/react";

import type { FormStatus } from "@/types/status";

import { ItemView, type Item } from "./item";

interface FormProps<T extends Record<string, unknown>> {
  id: string;
  defaultValues: DefaultValues<T>;
  resolver: Resolver<T>;
  heading: string;
  content: Item[];
  buttons: {
    back: string | null;
    next: string;
  };
  onBack: OnBack<T>;
  onNext: OnNext<T>;
  status: FormStatus;
  setStatus: (status: FormStatus) => void;
}

export function Form<T extends Record<string, unknown>>({
  id,
  onBack,
  onNext,
  status,
  setStatus,
  ...rest
}: FormProps<T>) {
  const [fields, setFields] = useState<T>();

  const move = useEffectEvent((move: FormStatus["move"]) => {
    if (move === "next") return onNext(fields);
    if (move === "back") return onBack(fields);
  });

  useEffect(() => move(status.move), [status.move]);

  const handleNext = useCallback<OnNext<T>>(
    (fields) => {
      setStatus({ type: "form", move: "next", submitting: false });
      setFields(fields);
    },
    [setStatus, setFields],
  );

  const handleBack = useCallback<OnBack<T>>(
    (fields) => {
      setStatus({ type: "form", move: "back", submitting: false });
      setFields(fields);
    },
    [setStatus, setFields],
  );

  const animate = useMemo(
    () => ({ x: 0, opacity: 1, transition: { delay: 0.25, duration: 0.25 } }),
    [],
  );

  return (
    <AnimatePresence mode="popLayout" initial={false}>
      <motion.div
        key={id}
        inert={Boolean(status.move)}
        animate={animate}
        onAnimationComplete={(definition) => {
          if (definition === animate) {
            setStatus({ type: "form", move: false, submitting: false });
          }
        }}
        {...motionProps(status.move)}
        className="h-full"
      >
        <Component
          onBack={handleBack}
          onNext={handleNext}
          status={status}
          {...rest}
        />
      </motion.div>
    </AnimatePresence>
  );
}

function motionProps(move: FormStatus["move"]): MotionProps {
  if (move === "next") {
    return {
      initial: { x: 50, opacity: 0 },
      exit: { x: -50, opacity: 0, transition: { delay: 0, duration: 0.25 } },
    };
  }
  if (move === "back") {
    return {
      initial: { x: -50, opacity: 0 },
      exit: { x: 50, opacity: 0, transition: { delay: 0, duration: 0.25 } },
    };
  }
  return {};
}

function Component<T extends Record<string, unknown>>({
  defaultValues,
  resolver,
  heading,
  content,
  buttons,
  onBack,
  onNext,
  status,
}: Omit<FormProps<T>, "id" | "setStatus">) {
  const form = useForm({ defaultValues, resolver });
  return (
    <form
      onSubmit={form.handleSubmit(onNext)}
      className="color-scheme-dark flex h-screen w-full items-center justify-center px-4 py-8"
      autoComplete="off"
    >
      <FormProvider {...form}>
        <div className="w-full max-w-md">
          <h2 className="mb-6 text-center text-4xl font-semibold text-white">
            {heading}
          </h2>
          <div className="mb-6 flex flex-col gap-4">
            {content.map((field, index) => (
              <ItemView key={index} {...field} />
            ))}
          </div>
          <div className="flex gap-4">
            {buttons.back && (
              <button
                type="button"
                disabled={status.submitting}
                onClick={() => onBack(form.getValues())}
                className="bg-neutral-90 w-full rounded-xl border border-neutral-800 px-6 py-2 text-base font-medium text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white active:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
              >
                {buttons.back}
              </button>
            )}
            <button
              type="submit"
              disabled={status.submitting}
              className="w-full rounded-xl border border-transparent bg-blue-500 px-6 py-2 text-base font-medium text-white transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white active:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
            >
              {status.submitting ? "Submitting..." : buttons.next}
            </button>
          </div>
        </div>
      </FormProvider>
    </form>
  );
}

Finally, we'll update app.tsx to include the new props in Form and update the status.

import { useCallback, useState } from "react";

import { Formity, type s, type Flow, type OnReturn } from "@formity/react";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

import type { Status, FormStatus } from "./types/status";

import { Form } from "./components/form";
import { Submitted } from "./components/submitted";

type Schema = {
  render: React.ReactNode;
  struct: [
    s.Form<{ name: string; surname: string; age: number }>,
    s.Form<{ softwareDeveloper: string }>,
    s.Condition<{
      then: [
        s.Form<{ expertise: string }>,
        s.Return<{
          name: string;
          surname: string;
          age: number;
          softwareDeveloper: true;
          expertise: string;
        }>,
      ];
      else: [
        s.Form<{ interested: string }>,
        s.Return<{
          name: string;
          surname: string;
          age: number;
          softwareDeveloper: false;
          interested: string;
        }>,
      ];
    }>,
  ];
  inputs: Record<never, never>;
  params: {
    status: FormStatus;
    setStatus: (status: FormStatus) => void;
  };
};

const flow: Flow<Schema> = [
  {
    form: {
      fields: () => ({
        name: ["", []],
        surname: ["", []],
        age: [20, []],
      }),
      render: ({ fields, params, onBack, onNext }) => (
        <Form
          id="yourself"
          defaultValues={fields}
          resolver={zodResolver(
            z.object({
              name: z.string().nonempty("Required"),
              surname: z.string().nonempty("Required"),
              age: z.number().min(18, "Min. 18").max(99, "Max. 99"),
            }),
          )}
          heading="Tell us about yourself"
          content={[
            {
              type: "columns",
              columns: [
                {
                  type: "input",
                  name: "name",
                  label: "Name",
                  placeholder: "Your name",
                },
                {
                  type: "input",
                  name: "surname",
                  label: "Surname",
                  placeholder: "Your surname",
                },
              ],
            },
            {
              type: "number",
              name: "age",
              label: "Age",
              placeholder: "Your age",
            },
          ]}
          buttons={{
            back: null,
            next: "Next",
          }}
          onBack={onBack}
          onNext={onNext}
          status={params.status}
          setStatus={params.setStatus}
        />
      ),
    },
  },
  {
    form: {
      fields: () => ({
        softwareDeveloper: ["", []],
      }),
      render: ({ fields, params, onBack, onNext }) => (
        <Form
          id="softwareDeveloper"
          defaultValues={fields}
          resolver={zodResolver(
            z.object({
              softwareDeveloper: z.string().nonempty("Required"),
            }),
          )}
          heading="Are you a software developer?"
          content={[
            {
              type: "select",
              name: "softwareDeveloper",
              label: "Software Developer",
              placeholder: "Select an option",
              options: [
                { value: "yes", label: "Yes" },
                { value: "no", label: "No" },
              ],
            },
          ]}
          buttons={{
            back: "Back",
            next: "Next",
          }}
          onBack={onBack}
          onNext={onNext}
          status={params.status}
          setStatus={params.setStatus}
        />
      ),
    },
  },
  {
    condition: {
      if: ({ softwareDeveloper }) => softwareDeveloper === "yes",
      then: [
        {
          form: {
            fields: () => ({
              expertise: ["", []],
            }),
            render: ({ fields, params, onBack, onNext }) => (
              <Form
                id="expertise"
                defaultValues={fields}
                resolver={zodResolver(
                  z.object({
                    expertise: z.string().nonempty("Required"),
                  }),
                )}
                heading="What is your area of expertise?"
                content={[
                  {
                    type: "select",
                    name: "expertise",
                    label: "Expertise",
                    placeholder: "Select an option",
                    options: [
                      { value: "frontend", label: "Frontend development" },
                      { value: "backend", label: "Backend development" },
                      { value: "mobile", label: "Mobile development" },
                    ],
                  },
                ]}
                buttons={{
                  back: "Back",
                  next: "Submit",
                }}
                onBack={onBack}
                onNext={onNext}
                status={params.status}
                setStatus={params.setStatus}
              />
            ),
          },
        },
        {
          return: ({ name, surname, age, expertise }) => ({
            name,
            surname,
            age,
            softwareDeveloper: true,
            expertise,
          }),
        },
      ],
      else: [
        {
          form: {
            fields: () => ({
              interested: ["", []],
            }),
            render: ({ fields, params, onBack, onNext }) => (
              <Form
                id="interested"
                defaultValues={fields}
                resolver={zodResolver(
                  z.object({
                    interested: z.string().nonempty("Required"),
                  }),
                )}
                heading="Are you interested in learning how to code?"
                content={[
                  {
                    type: "select",
                    name: "interested",
                    label: "Interested",
                    placeholder: "Select an option",
                    options: [
                      { value: "yes", label: "Yes, I am interested." },
                      { value: "no", label: "No, it is not for me." },
                      { value: "maybe", label: "Maybe, I am not sure." },
                    ],
                  },
                ]}
                buttons={{
                  back: "Back",
                  next: "Submit",
                }}
                onBack={onBack}
                onNext={onNext}
                status={params.status}
                setStatus={params.setStatus}
              />
            ),
          },
        },
        {
          return: ({ name, surname, age, interested }) => ({
            name,
            surname,
            age,
            softwareDeveloper: false,
            interested,
          }),
        },
      ],
    },
  },
];

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "form",
    move: false,
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
    setStatus({ type: "form", move: false, submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "submitted" });
  }, []);

  if (status.type === "submitted") {
    return (
      <Submitted
        onStart={() =>
          setStatus({ type: "form", move: false, submitting: false })
        }
      />
    );
  }

  return (
    <Formity<Schema>
      flow={flow}
      params={{ status, setStatus }}
      onReturn={onReturn}
    />
  );
}

Progress bar

We can also add a progress bar by making each form step return both the Form component and progress information. Then, we can use the useFormity hook to get this information.

import { useCallback, useState } from "react";

import { useFormity, type s, type Flow, type OnReturn } from "@formity/react";

import { motion } from "motion/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

import type { Status, FormStatus } from "./types/status";

import { Form } from "./components/form";
import { Submitted } from "./components/submitted";

type Schema = {
  render: {
    progress: {
      numberSteps: number;
      currentStep: number;
    };
    form: React.ReactNode;
  };
  struct: [
    s.Form<{ name: string; surname: string; age: number }>,
    s.Form<{ softwareDeveloper: string }>,
    s.Condition<{
      then: [
        s.Form<{ expertise: string }>,
        s.Return<{
          name: string;
          surname: string;
          age: number;
          softwareDeveloper: true;
          expertise: string;
        }>,
      ];
      else: [
        s.Form<{ interested: string }>,
        s.Return<{
          name: string;
          surname: string;
          age: number;
          softwareDeveloper: false;
          interested: string;
        }>,
      ];
    }>,
  ];
  inputs: Record<never, never>;
  params: {
    status: FormStatus;
    setStatus: (status: FormStatus) => void;
  };
};

const flow: Flow<Schema> = [
  {
    form: {
      fields: () => ({
        name: ["", []],
        surname: ["", []],
        age: [20, []],
      }),
      render: ({ fields, params, onBack, onNext }) => ({
        progress: {
          numberSteps: 3,
          currentStep: 1,
        },
        form: (
          <Form
            id="yourself"
            defaultValues={fields}
            resolver={zodResolver(
              z.object({
                name: z.string().nonempty("Required"),
                surname: z.string().nonempty("Required"),
                age: z.number().min(18, "Min. 18").max(99, "Max. 99"),
              }),
            )}
            heading="Tell us about yourself"
            content={[
              {
                type: "columns",
                columns: [
                  {
                    type: "input",
                    name: "name",
                    label: "Name",
                    placeholder: "Your name",
                  },
                  {
                    type: "input",
                    name: "surname",
                    label: "Surname",
                    placeholder: "Your surname",
                  },
                ],
              },
              {
                type: "number",
                name: "age",
                label: "Age",
                placeholder: "Your age",
              },
            ]}
            buttons={{
              back: null,
              next: "Next",
            }}
            onBack={onBack}
            onNext={onNext}
            status={params.status}
            setStatus={params.setStatus}
          />
        ),
      }),
    },
  },
  {
    form: {
      fields: () => ({
        softwareDeveloper: ["", []],
      }),
      render: ({ fields, params, onBack, onNext }) => ({
        progress: {
          numberSteps: 3,
          currentStep: 2,
        },
        form: (
          <Form
            id="softwareDeveloper"
            defaultValues={fields}
            resolver={zodResolver(
              z.object({
                softwareDeveloper: z.string().nonempty("Required"),
              }),
            )}
            heading="Are you a software developer?"
            content={[
              {
                type: "select",
                name: "softwareDeveloper",
                label: "Software Developer",
                placeholder: "Select an option",
                options: [
                  { value: "yes", label: "Yes" },
                  { value: "no", label: "No" },
                ],
              },
            ]}
            buttons={{
              back: "Back",
              next: "Next",
            }}
            onBack={onBack}
            onNext={onNext}
            status={params.status}
            setStatus={params.setStatus}
          />
        ),
      }),
    },
  },
  {
    condition: {
      if: ({ softwareDeveloper }) => softwareDeveloper === "yes",
      then: [
        {
          form: {
            fields: () => ({
              expertise: ["", []],
            }),
            render: ({ fields, params, onBack, onNext }) => ({
              progress: {
                numberSteps: 3,
                currentStep: 3,
              },
              form: (
                <Form
                  id="expertise"
                  defaultValues={fields}
                  resolver={zodResolver(
                    z.object({
                      expertise: z.string().nonempty("Required"),
                    }),
                  )}
                  heading="What is your area of expertise?"
                  content={[
                    {
                      type: "select",
                      name: "expertise",
                      label: "Expertise",
                      placeholder: "Select an option",
                      options: [
                        { value: "frontend", label: "Frontend development" },
                        { value: "backend", label: "Backend development" },
                        { value: "mobile", label: "Mobile development" },
                      ],
                    },
                  ]}
                  buttons={{
                    back: "Back",
                    next: "Submit",
                  }}
                  onBack={onBack}
                  onNext={onNext}
                  status={params.status}
                  setStatus={params.setStatus}
                />
              ),
            }),
          },
        },
        {
          return: ({ name, surname, age, expertise }) => ({
            name,
            surname,
            age,
            softwareDeveloper: true,
            expertise,
          }),
        },
      ],
      else: [
        {
          form: {
            fields: () => ({
              interested: ["", []],
            }),
            render: ({ fields, params, onBack, onNext }) => ({
              progress: {
                numberSteps: 3,
                currentStep: 3,
              },
              form: (
                <Form
                  id="interested"
                  defaultValues={fields}
                  resolver={zodResolver(
                    z.object({
                      interested: z.string().nonempty("Required"),
                    }),
                  )}
                  heading="Are you interested in learning how to code?"
                  content={[
                    {
                      type: "select",
                      name: "interested",
                      label: "Interested",
                      placeholder: "Select an option",
                      options: [
                        { value: "yes", label: "Yes, I am interested." },
                        { value: "no", label: "No, it is not for me." },
                        { value: "maybe", label: "Maybe, I am not sure." },
                      ],
                    },
                  ]}
                  buttons={{
                    back: "Back",
                    next: "Submit",
                  }}
                  onBack={onBack}
                  onNext={onNext}
                  status={params.status}
                  setStatus={params.setStatus}
                />
              ),
            }),
          },
        },
        {
          return: ({ name, surname, age, interested }) => ({
            name,
            surname,
            age,
            softwareDeveloper: false,
            interested,
          }),
        },
      ],
    },
  },
];

export default function App() {
  const [status, setStatus] = useState<Status>({
    type: "form",
    move: false,
    submitting: false,
  });

  const onReturn = useCallback<OnReturn<Schema>>(async (output) => {
    setStatus({ type: "form", move: false, submitting: true });

    // Show output in the console
    console.log(output);

    // Simulate a network request
    await new Promise((resolve) => setTimeout(resolve, 2000));

    setStatus({ type: "submitted" });
  }, []);

  if (status.type === "submitted") {
    return (
      <Submitted
        onStart={() =>
          setStatus({ type: "form", move: false, submitting: false })
        }
      />
    );
  }

  return <Formity params={{ status, setStatus }} onReturn={onReturn} />;
}

interface FormityProps {
  params: Schema["params"];
  onReturn: OnReturn<Schema>;
}

function Formity({ params, onReturn }: FormityProps) {
  const { progress, form } = useFormity({ flow, params, onReturn });
  return (
    <div className="relative h-full">
      <div className="absolute inset-x-0 top-0 z-10 h-1 bg-blue-500/50">
        <motion.div
          initial={false}
          animate={{
            transform: `scaleX(${progress.currentStep / progress.numberSteps})`,
          }}
          className="h-full origin-left bg-blue-500"
        />
      </div>
      {form}
    </div>
  );
}