DEV Community

Cover image for How to Build a fully accessible Accordion with HeadlessUI, Framer Motion, and TailwindCSS
Jyothikrishna
Jyothikrishna

Posted on • Edited on

How to Build a fully accessible Accordion with HeadlessUI, Framer Motion, and TailwindCSS

Before we get started, let's briefly discuss what each of these technologies does:

  • HeadlessUI: A set of fully accessible, lightweight, unstyled components for building UIs.
  • Framer Motion: A production-ready motion library for React that makes it easy to add animations and gestures to your UI.
  • TailwindCSS: A utility-first CSS framework that makes it easy to style your UI using pre-defined classes.

Now, let's dive into how to build an FAQ section with these tools.

Setup

To get started, you'll need to install the following dependencies to your react project:

     npm i headlessui framer-motion
Enter fullscreen mode Exit fullscreen mode

You can refer to tailwindcss docs on how to install and configure tailwindcss for the framework of your choice.

Creating the basic structure for accordion

Create a new tsx or jsx file by the name Accordion.{tsx/jsx} in your components folder and paste this block of code.

import { Disclosure } from "@headlessui/react";
import { ArrowSmallDownIcon } from "@heroicons/react/24/outline";

const Accordion = ({ question, answer }: FAQ) => {
  return (
    <Disclosure as="li">
      {({ open }) => (
        <>
          <Disclosure.Button className="font-semibold text-xl inline-flex items-center cursor-pointer justify-between w-full mb-1 text-neutral-800">
            {question}{" "}
            <span
              className={`p-2 hover:bg-zinc-400/30 rounded-full ${
                open ? "rotate-180" : ""
              }`}
            >
              <ArrowSmallDownIcon className="w-5 h-5" />
            </span>
          </Disclosure.Button>
          <Disclosure.Panel className="text-sm">{answer}</Disclosure.Panel>
        </>
      )}
    </Disclosure>
  );
};

export default Accordion;

Enter fullscreen mode Exit fullscreen mode

We make use of Disclosure component provided by headlessui. Our accordion accepts two props : question and answer, both are of string type.

Disclosure.Button is the component that toggles our disclosure/ accordion. So we render our question inside this component so that the users can see the answer when they click on the question.

Disclousre.Panel is the component that gets mounted and unmounted whenever user clicks on the Disclosure.Button.

We use the render prop open to conditionally rotate the icon. I am using ArrowSmallDownIcon from Heroicons. You may use the icon of your choice. You may find more on this topic here.

Adding animations with framer-motion

for answer

import { AnimatePresence } from "framer-motion";

<AnimatePresence>
  <Disclosure.Panel
    as={motion.div}
    initial={{ y: -20, opacity: 0.2 }}
    animate={{ y: 0, opacity: 1 }}
    exit={{
      y: -20,
      opacity: 0.2,
      transition: { duration: 0.2, type: "tween" },
    }}
    transition={{
      duration: 0.15,
      type: "tween",
    }}
    className="text-sm text-neutral-700"
  >
    {answer}
  </Disclosure.Panel>
</AnimatePresence>;

Enter fullscreen mode Exit fullscreen mode

We must wrap Disclosure.Panel component that displays the answer with AnimatePresence so that we can add exit animations to it. The as={motion.div} prop allows us to use the motion component from Framer Motion and apply animations to the content inside the Disclosure.Panel.

The animation we are going for is pretty straight forward. The answer slides from top and fades in when it is supposed to appear and slides to the top and fades out when it is supposed to disappear.

for question

This is quite simple when compared to the former. We just need to toggle the backgroundColor of the span that is holding our icon between transparent and an off white color which provides an instant feedback for our users that it is a clickable item.

<Disclosure.Button className="font-semibold text-xl inline-flex items-center cursor-pointer justify-between w-full mb-1 text-neutral-800">
  {question}{" "}
  <motion.span
    whileHover={{ backgroundColor: "rgb(161 161 170 / 0.3)" }}
    initial={{ backgroundColor: "transparent" }}
    animate={{ rotate: open ? 180 : 0 }}
    transition={{
      duration: 0.15,
      type: "tween",
    }}
    className="p-2 rounded-full text-neutral-950"
  >
    <ArrowSmallDownIcon className="w-5 h-5" />
  </motion.span>
</Disclosure.Button>;
Enter fullscreen mode Exit fullscreen mode

Note
You may customize the duration and easing of the animations to your liking.

Accordion component after adding animations

import { motion, AnimatePresence } from "framer-motion";
import { Disclosure } from "@headlessui/react";
import { ArrowSmallDownIcon } from "@heroicons/react/24/outline";

const Accordion = ({ question, answer }: FAQ) => {
  return (
    <Disclosure as="li">
      {({ open }) => (
        <>
          <Disclosure.Button className="font-semibold text-xl inline-flex items-center cursor-pointer justify-between w-full mb-1 text-neutral-800">
            {question}{" "}
            <motion.span
              whileHover={{ backgroundColor: "rgb(161 161 170 / 0.3)" }}
              initial={{ backgroundColor: "transparent" }}
              animate={{ rotate: open ? 180 : 0 }}
              transition={{
                duration: 0.15,
                type: "tween",
              }}
              className="p-2 rounded-full text-neutral-950"
            >
              <ArrowSmallDownIcon className="w-5 h-5" />
            </motion.span>
          </Disclosure.Button>
          <AnimatePresence>
            <Disclosure.Panel
              as={motion.div}
              initial={{ y: -20, opacity: 0.2 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{
                y: -20,
                opacity: 0.2,
                transition: { duration: 0.2, type: "tween" },
              }}
              transition={{
                duration: 0.15,
                type: "tween",
              }}
              className="text-sm text-neutral-700"
            >
              {answer}
            </Disclosure.Panel>
          </AnimatePresence>
        </>
      )}
    </Disclosure>
  );
};

export default Accordion;

Enter fullscreen mode Exit fullscreen mode

That's it folks !! By following these simple steps you have bult a fully accessible accordion component. You can use this to render an FAQ section. You may find an example on how to use this component below 👇.

import Accordion from "./Accordion";

const faqs: FAQ[] = [
  {
    question: "Is vite the best bundler ?",
    answer: `It's difficult to say whether ViteJS is the "best" bundler out there, as it ultimately depends on your specific needs and preferences. ViteJS has gained popularity due to its fast development server and quick build times, which can be beneficial for certain types of projects.`,
  },
  {
    question: "Why should I start using headlessui ?",
    answer:
      "HeadlessUI provides fully accessible, unstyled UI components that are flexible and customizable, can be used with any front-end framework, and are lightweight for optimal performance.",
  },
];

function App() {
  return (
    <main className="min-h-screen bg-fuchsia-400 grid place-items-center font-inter">
      <div className="bg-neutral-50 rounded-xl backdrop-blur-xl w-[70vw] p-12 min-h-[70vh]">
        <ul className="flex flex-col gap-4 mb-8">
          {faqs.map((faq, index) => (
            <Accordion key={index} {...faq} />
          ))}
        </ul>
      </div>
    </main>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

You can grab the full source code on github and here is a working demo for reference.

Happy Hacking

Top comments (0)