I made a disclosure component for the FAQ section of makeswift.com, and discovered Framer Motion has a great API for animating height.
In this article, I will be recreating the component and demonstrating how to animate height from 0 to auto. Here is a demo:
import { motion, AnimatePresence } from "framer-motion"; import { useState, ReactNode } from "react"; import { defaultFAQs } from "./defaultValues"; import { More, Less } from "./svgs"; type Props = { title: string; body: ReactNode; }; const Disclosure = (props: Props) => { const [isOpen, setIsOpen] = useState(false); return ( <div className="flex flex-col w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <button aria-controls={props.title} aria-expanded={isOpen} className="flex justify-between text-left items-center w-full space-x-4" > <div className="text-xl font-semibold">{props.title}</div> <AnimatePresence initial={false} mode="wait"> <motion.div key={isOpen ? "minus" : "plus"} initial={{ rotate: isOpen ? -90 : 90, }} animate={{ rotate: 0, transition: { type: "tween", duration: 0.15, ease: "circOut", }, }} exit={{ rotate: isOpen ? -90 : 90, transition: { type: "tween", duration: 0.15, ease: "circIn", }, }} > {isOpen ? <Less /> : <More />} </motion.div> </AnimatePresence> </button> <motion.div id={props.title} initial={false} animate={ isOpen ? { height: "auto", opacity: 1, display: "block", transition: { height: { duration: 0.4, }, opacity: { duration: 0.25, delay: 0.15, }, }, } : { height: 0, opacity: 0, transition: { height: { duration: 0.4, }, opacity: { duration: 0.25, }, }, transitionEnd: { display: "none", }, } } className="font-light" > {props.body} </motion.div> </div> ); }; export default function App() { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((faq, i) => ( <Disclosure key={i} title={faq.question} body={faq.answer} /> ))} </div> ); }
Component API and structure
I will start by defining our component API to include a title (string) and a body (ReactNode).
type Props = {
title: string
body: ReactNode
}And then make a component structure to use said Props:
const Disclosure = (props: Props) => {
const [isOpen, setIsOpen] = useState(false)
return (
<div
className="flex flex-col text-left w-full bg-[#EFEFEF] p-3 rounded-lg"
onClick={() => setIsOpen(prev => !prev)}
>
<button
aria-controls={props.title}
aria-expanded={isOpen}
className="flex justify-between items-center w-full"
>
<div className="text-2xl font-semibold">{props.title}</div>
<div>Svg Placeholder</div>
</button>
<div
id={props.title}
className={classNames(
'font-light',
isOpen ? 'block' : 'hidden',
)}
>
{props.body}
</div>
</div>
)
}You might be surprised to see that I didn't use the details element. This element natively implements the disclosure pattern that I am writing out by hand. I used the disclosure pattern with a button instead to have control over the show and hide animation.
<div onClick={} />- I wrap the component to separate my interactive element from the body
- I add the event handler preventing misclicks at the edge of my component
onClick={() => setIsOpen((prev) => !prev)}- I toggle
openstate in the onClick handler
- I toggle
<button />- I use a
buttonsince this is an interactive component that doesn't trigger navigation aria-controlsindicates that this button controls the content below which has a matchingidaria-expandedindicates whether or not our disclosure is open- I don't add
role='button'since this is implied by using thebuttonelement - I don't need an event handler because the event will bubble up to my parent element
- I use a
justify-betweensetsjustify-content: space-between;- I use flex to push the title and icons apart
className={classNames('font-light', isOpen ? 'block' : 'hidden')}- And I toggle between
display: blockanddisplay: noneas a placeholder for the animation we will add later
- And I toggle between
import classNames from "classnames"; import { useState, ReactNode } from "react"; import { defaultFAQs } from "./defaultValues"; type Props = { title: string; body: ReactNode; }; const Disclosure = (props: Props) => { const [isOpen, setIsOpen] = useState(false); return ( <div className="flex flex-col w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <button aria-controls={props.title} aria-expanded={isOpen} className="flex justify-between text-left items-center w-full space-x-4" > <div className="text-xl font-semibold">{props.title}</div> <div>Svg Placeholder</div> </button> <div id={props.title} className={classNames("font-light", isOpen ? "block" : "hidden")} > {props.body} </div> </div> ); }; export default function App() { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((faq, i) => ( <Disclosure key={i} title={faq.question} body={faq.answer} /> ))} </div> ); }
This is the bare minimum so let's add some more flair!
Icon API and Animation
To start, let's replace the icon placeholder with an animated icon.
Icons
This component toggles between two icons, which I am inlining as SVGs in React.
export const Less = (props: SVGProps<SVGSVGElement>) => (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="2"
y1="10"
x2="18"
y2="10"
stroke="black"
strokeWidth="4"
strokeLinecap="round"
/>
</svg>
)I then conditionally render the icons within the disclosure using a ternary.
<div className="flex justify-between items-center w-full">
<div className="text-2xl font-semibold">{props.title}</div>
- <div>Svg Placeholder</div>
+ {isOpen ? <Less /> : <More />}
</div>I increased the bundle size when I inlined the icons, so let's make sure their color API is reusable.
Using currentcolor
currentcolor is a color keyword, like transparent or lawngreen.
It represents the set value of the color property. I will update the icon to use it like so:
- export const Less = (props: SVGProps<SVGSVGElement>) => (
+ export const Less = ({
+ className,
+ ...props
+ }: SVGProps<SVGSVGElement>) => (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
+ className={`${className} text-black`}
+ stroke="currentcolor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="2"
y1="10"
x2="18"
y2="10"
- stroke="black"
strokeWidth="4"
strokeLinecap="round"
/>
</svg>
);({ className, ...props}: SVGProps<SVGSVGElement>)- I destructure the
classNamefrom props to specify explicitly whereclassNameis being set on the SVG
- I destructure the
className={`${className} text-black`}- I set the default
colorproperty toblackvia Tailwind. Shoutout to the haters.
- I set the default
stroke="currentcolor"- I set the
strokeof the SVG tocurrentcolor
- I set the
stroke="black"- And I remove the explicit stroke on my
line
- And I remove the explicit stroke on my
Adjusting icon color is now as easy as changing the className:
- {isOpen ? <Less /> : <More />}
+ {isOpen ? (
+ <Less className="text-teal-500" />
+ ) : (
+ <More className="text-teal-500" />
+ )}With currentcolor, there is no mapping of a color prop (JS) to the Tailwind theme (CSS). This is a big maintainability win as our theme changes over time.
Animating between icons
Now that we have created our icons, let's animate them.
<AnimatePresence initial={false} mode="wait">
<motion.div
key={isOpen ? 'less' : 'more'}
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><AnimatePresence initial={false} mode="wait">- I use
AnimatePresenceto animate theexitanimation initial={false}prevents icons from animating on initial page loadmode="wait"delays theenteranimation until the currentexitanimation has completed
- I use
key={isOpen ? 'less' : 'more'}AnimatePresencerequires an explicit key
rotate: isOpen ? -90 : 90- I invert rotation based on state. So it is clockwise on open and counter-clockwise on close
ease: 'circOut'vsease: 'circIn'- I use
circInonexitto make the icon speed up until it switches - I then use
circOutonenterto make the icon slow down - Together these two easing functions hide the toggle between the icons
- I use
Another way of doing the same animation is:
<AnimatePresence initial={false} mode="wait">
{isOpen ? (
<motion.div key={'less'}>
<Less />
</motion.div>
) : (
<motion.div key={'more'}>
<More />
</motion.div>
)}
</AnimatePresence>As opposed to what we did above which is:
<AnimatePresence initial={false} mode="wait">
<motion.div key={isOpen ? 'less' : 'more'}>
{isOpen ? <Less /> : <More />}
</motion.div>
</AnimatePresence>The conditional render is happening at a different level.
Because AnimatePresence requires a key, I can simply differentiate the divs via key.
For animations with two states, I prefer to look right to left at the different values in a ternary, rather than up and down at the different divs. This is just a preference!
Our icon is animating!
import classNames from "classnames"; import { AnimatePresence, motion } from "framer-motion"; import { useState, ReactNode } from "react"; import { defaultFAQs } from "./defaultValues"; import { Less, More } from "./svgs"; type Props = { title: string; body: ReactNode; }; const Disclosure = (props: Props) => { const [isOpen, setIsOpen] = useState(false); return ( <div className="flex flex-col w-full bg-[#EFEFEF] p-3 rounded-lg" onClick={() => setIsOpen((prev) => !prev)} > <button aria-controls={props.title} aria-expanded={isOpen} className="flex justify-between text-left items-center w-full space-x-4" > <div className="text-xl font-semibold">{props.title}</div> <AnimatePresence initial={false} mode="wait"> <motion.div key={isOpen ? "minus" : "plus"} initial={{ rotate: isOpen ? -90 : 90, }} animate={{ rotate: 0, transition: { type: "tween", duration: 0.15, ease: "circOut", }, }} exit={{ rotate: isOpen ? -90 : 90, transition: { type: "tween", duration: 0.15, ease: "circIn", }, }} > {isOpen ? <Less /> : <More />} </motion.div> </AnimatePresence> </button> <div id={props.title} className={classNames("font-light", isOpen ? "block" : "hidden")} > {props.body} </div> </div> ); }; export default function App() { return ( <div className="flex flex-col w-full p-5 justify-center items-center space-y-7"> {defaultFAQs.map((faq, i) => ( <Disclosure key={i} title={faq.question} body={faq.answer} /> ))} </div> ); }
It's time to work on the body animation ->
Body animation
CSS doesn't currently allow animations from 0 to auto. Framer Motion does.
initial={false}
animate={
isOpen
? {
height: 'auto',
opacity: 1,
display: 'block',
}
: {
height: 0,
opacity: 0,
transitionEnd: {
display: 'none',
},
}
}initial={false}sets the initial render to match theanimatepropertytransitionEnd: { display: 'none' }sets the display property at the end of the disappear animation- Text doesn't change height based on its parent. Instead, it overflows. So this is critical to prevent invisible overflow.
I could have also conditionally rendered the body with an AnimatePresence but editing the display property makes sure that the aria-controls in my button is still pointing to a valid id.
This article is a great summary of different solutions to the 0 to auto problem. Framer Motion falls into "Method 3." Check it out if you aren't using Framer Motion!
Body Clipping
The only problem now is that the animated height is clipping the body.
We can fix this with a well-timed transition.
initial={false}
animate={
isOpen
? {
height: "auto",
opacity: 1,
display: "block",
+ transition: {
+ height: {
+ duration: 0.4,
+ },
+ opacity: {
+ duration: 0.25,
+ delay: 0.15,
+ },
+ },
}
: {
height: 0,
opacity: 0,
+ transition: {
+ height: {
+ duration: 0.4,
+ },
+ opacity: {
+ duration: 0.25,
+ },
+ },
transitionEnd: {
display: "none",
},
}
}- I delay the opacity animation after height on
enter - And I extend the height animation past opacity on
exit
Our component is now complete (with all 37 pieces of flair)!
In summary
- When creating components check the aria-practices section of w3c.github.io to see if it falls into a common pattern
- Use
currentcolor! It's a great way to control color while keeping styles in CSS - and don't underestimate the impact of well-timed transitions
This post is a part of a series I am writing on animated and accessible components. (details here)
Until then — thanks for reading! May your components always be extensible and encapsulated.🫡