Advanced concepts

Animations

Learn how to add animations in these forms by using Motion.


First steps

To add animations in these forms we can use the Motion package. To learn how to do it, clone the following Github repository so that you don't need to start from scratch.

git clone https://github.com/martiserra99/formity-react-docs

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

Create form

We will start by creating a very basic form without using animations. For that, we will create a schema.tsx file with the following content:

// schema.tsx
import type { Schema, Form, Return } from "@formity/react";

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

import { Step, Layout, TextField, NextButton, BackButton } from "./components";

import { MultiStep } from "./multi-step";

export type Values = [
  Form<{ name: string }>,
  Form<{ surname: string }>,
  Return<{ name: string; surname: string }>,
];

export const schema: Schema<Values> = [
  {
    form: {
      values: () => ({
        name: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <MultiStep
          step="name"
          onNext={onNext}
          onBack={onBack}
          getState={getState}
          setState={setState}
        >
          <Step
            defaultValues={values}
            resolver={zodResolver(
              z.object({
                name: z.string(),
              }),
            )}
          >
            <Layout
              heading="What is your name?"
              description="We would like to know what is your name"
              fields={[<TextField key="name" name="name" label="Name" />]}
              button={<NextButton>Next</NextButton>}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    form: {
      values: () => ({
        surname: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <MultiStep
          step="surname"
          onNext={onNext}
          onBack={onBack}
          getState={getState}
          setState={setState}
        >
          <Step
            defaultValues={values}
            resolver={zodResolver(
              z.object({
                surname: z.string(),
              }),
            )}
          >
            <Layout
              heading="What is your surname?"
              description="We would like to know what is your surname"
              fields={[
                <TextField key="surname" name="surname" label="Surname" />,
              ]}
              button={<NextButton>Next</NextButton>}
              back={<BackButton />}
            />
          </Step>
        </MultiStep>
      ),
    },
  },
  {
    return: ({ name, surname }) => ({
      name,
      surname,
    }),
  },
];

Then, we will update the App.tsx file so that it contains the following:

// App.tsx
import { useCallback, useState } from "react";

import { Formity, OnReturn, ReturnValues } from "@formity/react";

import { Data } from "./components";

import { schema, Values } from "./schema";

export default function App() {
  const [values, setValues] = useState<ReturnValues<Values> | null>(null);

  const onReturn = useCallback<OnReturn<Values>>((values) => {
    setValues(values);
  }, []);

  if (values) {
    return <Data data={values} onStart={() => setValues(null)} />;
  }

  return <Formity<Values> schema={schema} onReturn={onReturn} />;
}

Animate form

To animate form transitions, we will update the multi-step/multi-step.tsx file so that it contains the following:

// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { OnNext, OnBack, GetState, SetState } from "@formity/react";

import { useCallback, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";

import { MultiStepContext } from "./multi-step-context";

interface MultiStepProps {
  step: string;
  onNext: OnNext;
  onBack: OnBack;
  getState: GetState;
  setState: SetState;
  children: ReactNode;
}

export function MultiStep({
  step,
  onNext,
  onBack,
  getState,
  setState,
  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, getState, setState }),
    [handleNext, handleBack, getState, setState],
  );

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

Here, we are using the AnimatePresence component to handle animations. It ensures that whenever the key prop changes, the corresponding component transitions smoothly, creating a more dynamic user experience.

We've also implemented a state to determine when an animation is in progress. During this time, navigation between steps is disabled by using the inert property. We could have also disabled the buttons instead.

Finally, the setTimeout function is used to guarantee that the animation state updates before moving on to the next step or previous step.

Different animations

We may want to use different animations depending on whether we are going to the next or previous step. To achieve it, we will need to update the file the following way:

// multi-step/multi-step.tsx
import type { ReactNode } from "react";
import type { MotionProps } from "motion/react";
import type { OnNext, OnBack, GetState, SetState } from "@formity/react";

import { useCallback, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";

import { MultiStepContext } from "./multi-step-context";

interface MultiStepProps {
  step: string;
  onNext: OnNext;
  onBack: OnBack;
  getState: GetState;
  setState: SetState;
  children: ReactNode;
}

export function MultiStep({
  step,
  onNext,
  onBack,
  getState,
  setState,
  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, getState, setState }),
    [handleNext, handleBack, getState, setState],
  );

  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 {};
  }
}

We've updated the state to track whether we're moving to the next or previous step and adjusted the animation props accordingly.

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 } from "@formity/react";

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

import {
  Screen,
  Step,
  Layout,
  TextField,
  NextButton,
  BackButton,
} from "./components";

import { MultiStep } from "./multi-step";

export type Values = [
  Form<{ name: string }>,
  Form<{ surname: string }>,
  Return<{ name: string; surname: string }>,
];

export const schema: Schema<Values> = [
  {
    form: {
      values: () => ({
        name: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <Screen progress={{ total: 2, current: 1 }}>
          <MultiStep
            step="name"
            onNext={onNext}
            onBack={onBack}
            getState={getState}
            setState={setState}
          >
            <Step
              defaultValues={values}
              resolver={zodResolver(
                z.object({
                  name: z.string(),
                }),
              )}
            >
              <Layout
                heading="What is your name?"
                description="We would like to know what is your name"
                fields={[<TextField key="name" name="name" label="Name" />]}
                button={<NextButton>Next</NextButton>}
              />
            </Step>
          </MultiStep>
        </Screen>
      ),
    },
  },
  {
    form: {
      values: () => ({
        surname: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <Screen progress={{ total: 2, current: 2 }}>
          <MultiStep
            step="surname"
            onNext={onNext}
            onBack={onBack}
            getState={getState}
            setState={setState}
          >
            <Step
              defaultValues={values}
              resolver={zodResolver(
                z.object({
                  surname: z.string(),
                }),
              )}
            >
              <Layout
                heading="What is your surname?"
                description="We would like to know what is your surname"
                fields={[
                  <TextField key="surname" name="surname" label="Surname" />,
                ]}
                button={<NextButton>Next</NextButton>}
                back={<BackButton />}
              />
            </Step>
          </MultiStep>
        </Screen>
      ),
    },
  },
  {
    return: ({ name, surname }) => ({
      name,
      surname,
    }),
  },
];