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
).
And then make a component structure to use said Props
:
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
open
state in the onClick handler
- I toggle
<button />
- I use a
button
since this is an interactive component that doesn't trigger navigation aria-controls
indicates that this button controls the content below which has a matchingid
aria-expanded
indicates whether or not our disclosure is open- I don't add
role='button'
since this is implied by using thebutton
element - I don't need an event handler because the event will bubble up to my parent element
- I use a
justify-between
setsjustify-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: block
anddisplay: none
as 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.
I then conditionally render the icons within the disclosure using a ternary.
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:
({ className, ...props}: SVGProps<SVGSVGElement>)
- I destructure the
className
from props to specify explicitly whereclassName
is being set on the SVG
- I destructure the
className={`${className} text-black`}
- I set the default
color
property toblack
via Tailwind. Shoutout to the haters.
- I set the default
stroke="currentcolor"
- I set the
stroke
of 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
:
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">
- I use
AnimatePresence
to animate theexit
animation initial={false}
prevents icons from animating on initial page loadmode="wait"
delays theenter
animation until the currentexit
animation has completed
- I use
key={isOpen ? 'less' : 'more'}
AnimatePresence
requires 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
circIn
onexit
to make the icon speed up until it switches - I then use
circOut
onenter
to 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:
As opposed to what we did above which is:
The conditional render is happening at a different level.
Because AnimatePresence
requires a key
, I can simply differentiate the div
s 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}
sets the initial render to match theanimate
propertytransitionEnd: { 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
.
- 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.🫡