Advanced concepts
Animations
Learn how to add animations in these forms by using Framer Motion.
First steps
To create animations in these forms we can use the Framer Motion package. To learn how to do it clone the following Github repository so that you don't need to start everything from scratch.
git clone https://github.com/martiserra99/formity-docs-animations
Make sure you run the following command to install all the dependencies:
npm install
Additionally, you also need to install Framer Motion by doing the following:
npm install framer-motion
Animate form
To animate form transitions, we will use the AnimatePresence
component in the file components/form.tsx
. The content of this component will be the form, and we will use the key
prop with the step
value to animate when it enters and when it leaves.
// src/components/form.tsx
import { forwardRef, ReactElement, useCallback } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Step, DefaultValues, Resolver, OnNext, Variables } from "formity";
import { AnimatePresence, motion } from "framer-motion";
interface FormProps {
step: Step;
defaultValues: DefaultValues;
resolver: Resolver;
onNext: OnNext;
children: ReactElement;
}
export default function Form({
step,
defaultValues,
resolver,
onNext,
children,
}: FormProps) {
return (
<AnimatePresence mode="popLayout" initial={false}>
<MotionComponent
key={step}
defaultValues={defaultValues}
resolver={resolver}
onNext={onNext}
initial={{ opacity: 0, x: 100 }}
animate={{
x: 0,
opacity: 1,
transition: { delay: 0.25, duration: 0.5 },
}}
exit={{
x: -100,
opacity: 0,
transition: { delay: 0, duration: 0.25 },
}}
>
{children}
</MotionComponent>
</AnimatePresence>
);
}
interface ComponentProps {
defaultValues: DefaultValues;
resolver: Resolver;
onNext: OnNext;
children: ReactElement;
}
const Component = forwardRef<HTMLFormElement, ComponentProps>(
function Component({ defaultValues, resolver, onNext, children }, ref) {
const form = useForm({ defaultValues, resolver });
const handleSubmit = useCallback(
(formData: Variables) => {
onNext(formData);
},
[onNext],
);
return (
<form
ref={ref}
onSubmit={form.handleSubmit(handleSubmit)}
className="h-full"
>
<FormProvider {...form}>{children}</FormProvider>
</form>
);
},
);
const MotionComponent = motion(Component);
We will also need to change the components.tsx
file so that we use the step
prop instead of the key
prop.
// src/components.tsx
// ...
const components: Components<Parameters> = {
form: ({ step, defaultValues, resolver, onNext, children }, render) => (
<Form
step={step}
defaultValues={defaultValues}
resolver={resolver}
onNext={onNext}
>
{render(children)}
</Form>
),
// ...
};
// ...
Now that we have done that, we can see that there are already some animations when we go to the next 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 do it, we will create an animate.ts
file with a context that contains data about the current animation that needs to be performed.
// src/animate.ts
import { createContext, Dispatch, SetStateAction, useContext } from "react";
export type Animate = "none" | "next" | "back";
export interface AnimateValue {
animate: Animate;
setAnimate: Dispatch<SetStateAction<Animate>>;
}
export const AnimateContext = createContext<AnimateValue | null>(null);
export function useAnimate(): AnimateValue {
const context = useContext(AnimateContext);
if (!context) {
throw new Error(
"useAnimate must be used within an AnimateContext.Provider",
);
}
return context;
}
Then, we will provide the context in the Form
component, and we will apply a different animation depending on whether we are going to the next or previous step.
// src/components/form.tsx
import {
forwardRef,
ReactElement,
useCallback,
useState,
useMemo,
} from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Step, DefaultValues, Resolver, OnNext, Variables } from "formity";
import { AnimatePresence, motion, MotionProps } from "framer-motion";
import { Animate, AnimateContext, useAnimate } from "@/animate";
interface FormProps {
step: Step;
defaultValues: DefaultValues;
resolver: Resolver;
onNext: OnNext;
children: ReactElement;
}
export default function Form({
step,
defaultValues,
resolver,
onNext,
children,
}: FormProps) {
const [animate, setAnimate] = useState<Animate>("none");
const value = useMemo(() => ({ animate, setAnimate }), [animate, setAnimate]);
return (
<AnimateContext.Provider value={value}>
<AnimatePresence
mode="popLayout"
onExitComplete={() => setAnimate("none")}
initial={false}
>
<MotionComponent
key={step}
defaultValues={defaultValues}
resolver={resolver}
onNext={onNext}
animate={{
x: 0,
opacity: 1,
transition: { delay: 0.25, duration: 0.5 },
}}
{...motionProps(animate)}
>
{children}
</MotionComponent>
</AnimatePresence>
</AnimateContext.Provider>
);
}
function motionProps(animate: Animate): 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 {};
}
interface ComponentProps {
defaultValues: DefaultValues;
resolver: Resolver;
onNext: OnNext;
children: ReactElement;
}
const Component = forwardRef<HTMLFormElement, ComponentProps>(
function Component({ defaultValues, resolver, onNext, children }, ref) {
const form = useForm({ defaultValues, resolver });
const { setAnimate } = useAnimate();
const handleSubmit = useCallback(
(formData: Variables) => {
setAnimate("next");
setTimeout(() => onNext(formData), 0);
},
[onNext, setAnimate],
);
return (
<form
ref={ref}
onSubmit={form.handleSubmit(handleSubmit)}
className="h-full"
>
<FormProvider {...form}>{children}</FormProvider>
</form>
);
},
);
const MotionComponent = motion(Component);
As you may have noticed we are using a setTimeout
in the handleSubmit
function. The reason for that is that we need to go to the next step after we have changed the value of the context to ensure that we perform the correct animation. If we don't do that it won't work as expected.
The same thing needs to be done in the Back
component.
// src/components/navigation/back.tsx
import { useCallback } from "react";
import { useFormContext } from "react-hook-form";
import { OnBack } from "formity";
import { ChevronLeftIcon } from "@heroicons/react/20/solid";
import { cn } from "@/utils";
import { useAnimate } from "@/animate";
interface BackProps {
onBack: OnBack;
}
export default function Back({ onBack }: BackProps) {
const { getValues } = useFormContext();
const { setAnimate } = useAnimate();
const handleClick = useCallback(() => {
setAnimate("back");
setTimeout(() => onBack(getValues()), 0);
}, [onBack, setAnimate, getValues]);
return (
<button
type="button"
onClick={handleClick}
className={cn(
"block rounded-full border border-neutral-800 bg-neutral-950 px-6 py-2 hover:bg-neutral-800",
"focus:outline-none focus:ring-2 focus:ring-white/10 focus:ring-offset-2 focus:ring-offset-black",
"disabled:bg-neutral-950 disabled:opacity-60",
)}
>
<ChevronLeftIcon className="pointer-events-none size-5 fill-white" />
</button>
);
}
As you can see, we have a different animation when we go to the next or previous step. However, there is one last thing that needs to be addressed to avoid some weird behaviours.
Disable navigation
If we go to the next or previous step while the animation is being performed we may face some weird behaviours. To ensure that doesn't happen we can disable the buttons while the animation is being performed.
We need to update the Next
component:
// src/components/navigation/next.tsx
// ...
import { useAnimate } from "@/animate";
interface NextProps {
children: ReactNode;
}
export default function Next({ children }: NextProps) {
const { animate } = useAnimate();
return <Button disabled={animate !== "none"}>{children}</Button>;
}
We also need to update the Back
component:
// src/components/navigation/back.tsx
// ...
import { useAnimate } from "@/animate";
interface BackProps {
onBack: OnBack;
}
export default function Back({ onBack }: BackProps) {
const { getValues } = useFormContext();
const { animate, setAnimate } = useAnimate();
const handleClick = useCallback(() => {
setAnimate("back");
setTimeout(() => onBack(getValues()), 0);
}, [onBack, setAnimate, getValues]);
return (
<button
type="button"
onClick={handleClick}
className={cn(
"block rounded-full border border-neutral-800 bg-neutral-950 px-6 py-2 hover:bg-neutral-800",
"focus:outline-none focus:ring-2 focus:ring-white/10 focus:ring-offset-2 focus:ring-offset-black",
"disabled:bg-neutral-950 disabled:opacity-60",
)}
disabled={animate !== "none"}
>
<ChevronLeftIcon className="pointer-events-none size-5 fill-white" />
</button>
);
}