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

Basic 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 { FormView, FormLayout, TextField, Next, Back } from "./components";

import { Controller } from "./controller";

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 }) => (
        <Controller
          step="name"
          onNext={onNext}
          onBack={onBack}
          getState={getState}
          setState={setState}
        >
          <FormView
            defaultValues={values}
            resolver={zodResolver(
              z.object({
                name: z.string(),
              }),
            )}
          >
            <FormLayout
              heading="What is your name?"
              description="We would like to know what is your name"
              fields={[<TextField key="name" name="name" label="Name" />]}
              button={<Next>Next</Next>}
            />
          </FormView>
        </Controller>
      ),
    },
  },
  {
    form: {
      values: () => ({
        surname: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <Controller
          step="surname"
          onNext={onNext}
          onBack={onBack}
          getState={getState}
          setState={setState}
        >
          <FormView
            defaultValues={values}
            resolver={zodResolver(
              z.object({
                surname: z.string(),
              }),
            )}
          >
            <FormLayout
              heading="What is your surname?"
              description="We would like to know what is your surname"
              fields={[
                <TextField key="surname" name="surname" label="Surname" />,
              ]}
              button={<Next>Next</Next>}
              back={<Back />}
            />
          </FormView>
        </Controller>
      ),
    },
  },
  {
    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 controller/controller.tsx file so that it contains the following:

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

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

import { ControllerContext } from "./controller-context";

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

export function Controller({
  step,
  onNext,
  onBack,
  getState,
  setState,
  children,
}: ControllerProps) {
  const values = useMemo(
    () => ({
      onNext,
      onBack,
      getState,
      setState,
    }),
    [onNext, onBack, getState, setState],
  );

  return (
    <AnimatePresence mode="popLayout" initial={false}>
      <motion.div
        key={step}
        initial={{ opacity: 0, x: 100 }}
        animate={{
          x: 0,
          opacity: 1,
          transition: { delay: 0.25, duration: 0.25 },
        }}
        exit={{
          x: -100,
          opacity: 0,
          transition: { delay: 0, duration: 0.25 },
        }}
        className="h-full"
      >
        <ControllerContext.Provider value={values}>
          {children}
        </ControllerContext.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.

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:

// controller/controller.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 { ControllerContext } from "./controller-context";

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

export function Controller({
  step,
  onNext,
  onBack,
  getState,
  setState,
  children,
}: ControllerProps) {
  const [animate, setAnimate] = useState<"none" | "next" | "back">("none");

  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("none")}
    >
      <motion.div
        key={step}
        initial={{ opacity: 0, x: 100 }}
        animate={{
          x: 0,
          opacity: 1,
          transition: { delay: 0.25, duration: 0.25 },
        }}
        {...motionProps(animate)}
        className="h-full"
      >
        <ControllerContext.Provider value={values}>
          {children}
        </ControllerContext.Provider>
      </motion.div>
    </AnimatePresence>
  );
}

function motionProps(animate: "none" | "next" | "back"): MotionProps {
  if (animate === "next") {
    return {
      initial: { x: 100, opacity: 0 },
      exit: {
        x: -100,
        opacity: 0,
        transition: { delay: 0, duration: 0.25 },
      },
    };
  }
  if (animate === "back") {
    return {
      initial: { x: -100, opacity: 0 },
      exit: {
        x: 100,
        opacity: 0,
        transition: { delay: 0, duration: 0.25 },
      },
    };
  }
  return {};
}

We've created a state to control the animation that should be performed. Additionally, the functions to navigate between steps are called using setTimeout. This ensures the animation state is updated beforehand, allowing the correct animation to run smoothly.

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,
  FormView,
  FormLayout,
  TextField,
  Next,
  Back,
} from "./components";

import { Controller } from "./controller";

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 }}>
          <Controller
            step="name"
            onNext={onNext}
            onBack={onBack}
            getState={getState}
            setState={setState}
          >
            <FormView
              defaultValues={values}
              resolver={zodResolver(
                z.object({
                  name: z.string(),
                }),
              )}
            >
              <FormLayout
                heading="What is your name?"
                description="We would like to know what is your name"
                fields={[<TextField key="name" name="name" label="Name" />]}
                button={<Next>Next</Next>}
              />
            </FormView>
          </Controller>
        </Screen>
      ),
    },
  },
  {
    form: {
      values: () => ({
        surname: ["", []],
      }),
      render: ({ values, onNext, onBack, getState, setState }) => (
        <Screen progress={{ total: 2, current: 2 }}>
          <Controller
            step="surname"
            onNext={onNext}
            onBack={onBack}
            getState={getState}
            setState={setState}
          >
            <FormView
              defaultValues={values}
              resolver={zodResolver(
                z.object({
                  surname: z.string(),
                }),
              )}
            >
              <FormLayout
                heading="What is your surname?"
                description="We would like to know what is your surname"
                fields={[
                  <TextField key="surname" name="surname" label="Surname" />,
                ]}
                button={<Next>Next</Next>}
                back={<Back />}
              />
            </FormView>
          </Controller>
        </Screen>
      ),
    },
  },
  {
    return: ({ name, surname }) => ({
      name,
      surname,
    }),
  },
];