If you clicked on this snippet is likely that you are trying to transition content smoothly without making the surrounding content jump around.
Let's first make sure you understand how to animate the height
and width
properties and then we will move onto avoiding layout jumps.
Animating height
and width
Like other properties, width
and height
can be animated via the animate
prop.
import { useState } from "react"; import { motion } from "framer-motion"; export default function BasicExample () { const [isBig, setIsBig] = useState(false); return ( <div className="flex flex-col justify-center items-center space-y-7 p-10"> <motion.div className="bg-black" initial={false} animate={{ height: isBig ? 200 : 100, width: isBig ? 200 : 100, }} ></motion.div> <button onClick={() => setIsBig((isBig) => !isBig)} className="border-2 border-black text-black font-medium decoration-0 px-2.5 pb-0.5 pt-1 cursor-pointer" > {isBig ? "Make it small" : "Make it big"} </button> </div> ); };
Pretty simple :)
Animating from 0
to auto
Ok so the above is great but most of the time we want to animate height
in order to smooth the transition of content. Here is an example:
import { motion, AnimatePresence } from "framer-motion" import { useState } from "react" import { FAQ } from "./types" import { defaultFAQs } from "./defaultValues" import { More, Less } from "./svgs" function FAQItem(props: FAQ) { const [isOpen, setIsOpen] = useState(false); return ( <button className="flex flex-col text-left w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <div className="flex justify-between items-center w-full"> <div className="text-2xl font-semibold">{props.question}</div> <AnimatePresence initial={false} mode="wait"> <motion.div key={isOpen ? "minus" : "plus"} initial={{ rotate: isOpen ? -90 : 90, }} animate={{ zIndex: 1, rotate: 0, transition: { type: "tween", duration: 0.15, ease: "circOut", }, }} exit={{ zIndex: 0, rotate: isOpen ? -90 : 90, transition: { type: "tween", duration: 0.15, ease: "circIn", }, }} > {isOpen ? <Less /> : <More />} </motion.div> </AnimatePresence> </div> {isOpen && <div className="font-light">{props.answer}</div>} </button> ); } export default function FAQComponent() { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((FAQ, i) => ( <FAQItem key={i} {...FAQ} /> ))} </div> ); }
This FAQ component is a bit jarring due to the layout shift of toggling a question "open".
Thankfully Framer Motion allows us to animate our height
from 0px
to auto
.
import { motion, AnimatePresence } from "framer-motion" import { useState } from "react" import { FAQ } from "./types" import { defaultFAQs } from "./defaultValues" import { More, Less } from "./svgs" const FAQItem = (props: Faq) => { const [isOpen, setIsOpen] = useState(false); return ( <button className="flex flex-col text-left w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <div className="flex justify-between items-center w-full"> <div className="text-2xl font-semibold">{props.question}</div> <AnimatePresence initial={false} mode="wait"> <motion.div key={isOpen ? "minus" : "plus"} initial={{ rotate: isOpen ? -90 : 90, }} animate={{ zIndex: 1, rotate: 0, transition: { type: "tween", duration: 0.15, ease: "circOut", }, }} exit={{ zIndex: 0, rotate: isOpen ? -90 : 90, transition: { type: "tween", duration: 0.15, ease: "circIn", }, }} > {isOpen ? <Less /> : <More />} </motion.div> </AnimatePresence> </div> <AnimatePresence> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0, }} animate={{ height: "auto", opacity: 1, }} exit={{ height: 0, opacity: 0, }} key={props.answer} className="font-light" > {props.answer} </motion.div> )} </AnimatePresence> </button> ); }; export default function FAQComponent() { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((FAQ, i) => ( <FAQItem key={i} {...FAQ} /> ))} </div> ); }
Adding better transition
The only thing really left in this example is to update the transition
properties of our animation so that the text doesn't get visually clipped by our height.
On exit
, we are setting the opacity duration so that it finishes before the height gets too small, and on animate
, we are delaying the opacity
animation so that it starts after the height
animation has gotten a head starts.
import { motion, AnimatePresence } from "framer-motion" import { useState } from "react" import { FAQ } from "./types" import { defaultFAQs } from "./defaultValues" import { More, Less } from "./svgs" const FAQItem = (props: Faq) => { const [isOpen, setIsOpen] = useState(false); return ( <button className="flex flex-col text-left w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <div className="flex justify-between items-center w-full"> <div className="text-2xl font-semibold">{props.question}</div> <AnimatePresence initial={false} mode="wait"> <motion.div key={isOpen ? "minus" : "plus"} initial={{ rotate: isOpen ? -90 : 90, }} animate={{ zIndex: 1, rotate: 0, transition: { type: "tween", duration: 0.15, ease: "circOut", }, }} exit={{ zIndex: 0, rotate: isOpen ? -90 : 90, transition: { type: "tween", duration: 0.15, ease: "circIn", }, }} > {isOpen ? <Less /> : <More />} </motion.div> </AnimatePresence> </div> <AnimatePresence mode="wait"> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0, }} animate={{ height: "auto", opacity: 1, transition: { height: { duration: 0.4, }, opacity: { duration: 0.25, delay: 0.15, }, }, }} exit={{ height: 0, opacity: 0, transition: { height: { duration: 0.4, }, opacity: { duration: 0.25, }, }, }} key="test" className="font-light" > {props.answer} </motion.div> )} </AnimatePresence> </button> ); }; export default function FAQComponent () { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((c, i) => ( <FAQItem key={i} {...c} /> ))} </div> ); };
I created a further breakdown of how this component works with accessibility upgrades here if you want more details.
Hope this was helpful! If you still are confused HMU with any feedback you have at the email in the footer :)