React disclosure component

Oct 17, 2022 (Updated: Aug 7, 2023)

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 open state in the onClick handler
  • <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 matching id
    • aria-expanded indicates whether or not our disclosure is open
    • I don't add role='button' since this is implied by using the button element
    • I don't need an event handler because the event will bubble up to my parent element
  • justify-between sets justify-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 and display: none as a placeholder for the animation we will add later
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 className from props to specify explicitly where className is being set on the SVG
  • className={`${className} text-black`}
    • I set the default color property to black via Tailwind. Shoutout to the haters.
  • stroke="currentcolor"
    • I set the stroke of the SVG to currentcolor
  • stroke="black"
    • And I remove the explicit stroke on my line

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 AnimatePresence to animate the exit animation
    • initial={false} prevents icons from animating on initial page load
    • mode="wait" delays the enter animation until the current exit animation has completed
  • key={isOpen ? 'less' : 'more'}
  • rotate: isOpen ? -90 : 90
    • I invert rotation based on state. So it is clockwise on open and counter-clockwise on close
  • ease: 'circOut' vs ease: 'circIn'
    • I use circIn on exit to make the icon speed up until it switches
    • I then use circOut on enter to make the icon slow down
    • Together these two easing functions hide the toggle between the icons

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 the animate property
  • transitionEnd: { 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

  1. When creating components check the aria-practices section of w3c.github.io to see if it falls into a common pattern
  2. Use currentcolor! It's a great way to control color while keeping styles in CSS
  3. 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.🫡

Subscribe to the newsletter

A monthly no filler update.

Contact me at