FAQs (Frequently Asked Questions) are a common feature on websites. Instead of listing all the answers at once, an FAQ accordion lets users expand and collapse sections, keeping the page clean and easy to navigate.
This blog post explains how to build a fully functional animated FAQ accordion in React and Tailwind CSS, giving you a reusable and customizable component for any project. I’ll also provide a PropTypes version at the end in case you are not using TypeScript in your project.
Building the FAQ accordion component
In your React project (this could be a Next.js project, too), create a component and name it FAQAccordion. This will serve as the parent component responsible for rendering the list of FAQ items. It will accept an array of FAQs as a prop and manage which item is currently open.
Inside the component, we need to import a few things:
useState
— This will be used to track which question is open by its index.useRef
— This will help with dynamic height animation when expanding/collapsing an answer.- Icons from
lucide-react
— purely for UI purposes (optional).
Here’s the import statement:
import { useState, useRef } from 'react';
import { Minus, Plus } from 'lucide-react';
Since this component will receive an array of FAQs, each containing a question and an answer, we should define the expected structure using TypeScript. This improves type safety, ensuring we don’t accidentally pass incorrect props.
interface FAQ {
question: string;
answer: string;
}
interface FAQAccordionProps {
faqs: FAQ[];
defaultOpenIndex?: number | null;
}
At this point, everything is straightforward. Each FAQ
is an object with a question
and an answer
. The FAQAccordionProps
interface defines the faqs
array as required while making defaultOpenIndex
optional. This allows us to set a specific FAQ to be open by default when the component renders—something you might have seen on websites where one question is expanded by default.
Now, let’s create the component, pass in these props, and validate them using the interfaces we just defined.
export default function FAQAccordion({
faqs,
defaultOpenIndex = null,
}: FAQAccordionProps) {
const [selected, setSelected] =
(useState < number) | (null > defaultOpenIndex);
return (
<ul>
{faqs.map((faq, index) => (
<AccordionItem
key={index}
value={index}
question={faq.question}
answer={faq.answer}
selected={selected}
setSelected={setSelected}
/>
))}
</ul>
);
}
Inside this component, we use the useState
hook to manage which item is currently open. The state, selected
, stores the index of the open FAQ item (or null
if none are open).
We then loop through the faqs
array and render AccordionItem
components, which we would create in this same component, passing down the necessary props.
Create the accordion item component
Now that we have the FAQAccordion
component handling the list of FAQs, let’s define AccordionItem
. This will be the child component responsible for displaying each FAQ item.
Before writing the component, we first define its props.
interface AccordionItemProps {
value: number;
question: string;
answer: string;
selected: number | null;
setSelected: (index: number | null) => void;
}
This ensures that each AccordionItem
receives the correct data types. The value
prop represents the index of the item, while selected
tracks which item is open. The setSelected
function allows AccordionItem
to update the open/close state.
Next, create the component and pass these props into it.
const AccordionItem = ({
value,
question,
answer,
selected,
setSelected,
}: AccordionItemProps) => {
// ...
};
To determine whether this specific FAQ is open, we compare its value
with selected
.
const isOpen = selected === value;
If isOpen
is true
, this item is currently open; otherwise, it's closed. This will be useful when writing conditions inside the JSX.
To achieve smooth height animations, we use useRef
to store a reference to the answer <div>
, allowing us to dynamically adjust its height when opening or collapsing.
const contentRef = useRef < HTMLDivElement > null;
const Icon = isOpen ? Minus : Plus;
The core logic is in place at this point. Let’s write the JSX that renders each FAQ item.
return (
<li className="border-b">
<button
onClick={() => setSelected(isOpen ? null : value)}
className={`w-full flex justify-between items-center py-4 font-medium ${
isOpen ? 'text-blue-500' : 'text-gray-900'
}`}
>
{question}
<Icon size={20} className="transition-transform duration-300" />
</button>
<div
className="overflow-hidden transition-all duration-300"
style={{
height: isOpen ? contentRef.current?.scrollHeight || 'auto' : 0,
}}
>
<div
className="prose prose-lg text-gray-700"
ref={contentRef}
dangerouslySetInnerHTML={{ __html: answer }}
/>
</div>
</li>
);
When the button is clicked, it updates selected
to either open or close the FAQ. The answer section uses overflow-hidden
and dynamic height animation. The key part is style={{ height: isOpen ? contentRef.current?.scrollHeight || "auto" : 0 }}
, which smoothly adjusts the height of the answer when it opens or closes.
You’ll notice we are using dangerouslySetInnerHTML
inside the answer div. This is useful when working with rich text from a CMS, allowing HTML content to be rendered directly instead of being escaped by React.
Feel free to not do it this way. You can just render the answer this way
<div className="p-4 text-gray-600" ref={contentRef}>
{answer}
</div>
Using the FAQ component
Now that our components are ready, let’s use them in our project.
import FAQAccordion from './FAQAccordion';
const faqs = [
{
question: 'What is React?',
answer: 'React is a JavaScript library for building user interfaces.',
},
{
question: 'What is useState?',
answer: 'useState is a React hook that lets you manage component state.',
},
{
question: 'What is useRef?',
answer:
'useRef is a React hook that lets you persist values across renders without causing re-renders.',
},
];
export default function App() {
return (
<div className="max-w-lg mx-auto mt-10">
<h1 className="text-2xl font-bold mb-4">FAQ</h1>
<FAQAccordion faqs={faqs} defaultOpenIndex={0} />
</div>
);
}
Full code for FAQ accordion using TypeScript
Here’s the complete TypeScript version of the FAQAccordion
component, including all the logic and animations we’ve discussed. You can copy and use it directly in your project.
'use client';
import { useState, useRef } from 'react';
import { Minus, Plus } from 'lucide-react';
interface FAQ {
question: string;
answer: string;
}
interface FAQAccordionProps {
faqs: FAQ[];
defaultOpenIndex?: number | null;
}
export default function FAQAccordion({
faqs,
defaultOpenIndex = null,
}: FAQAccordionProps) {
const [selected, setSelected] =
(useState < number) | (null > defaultOpenIndex);
return (
<ul>
{faqs.map((faq, index) => (
<AccordionItem
key={index}
value={index}
question={faq.question}
answer={faq.answer}
selected={selected}
setSelected={setSelected}
/>
))}
</ul>
);
}
interface AccordionItemProps {
value: number;
question: string;
answer: string;
selected: number | null;
setSelected: (index: number | null) => void;
}
function AccordionItem({
value,
question,
answer,
selected,
setSelected,
}: AccordionItemProps) {
const isOpen = selected === value;
const contentRef = useRef < HTMLDivElement > null;
const Icon = isOpen ? Minus : Plus;
return (
<li className="border-b">
<button
onClick={() => setSelected(isOpen ? null : value)}
className={`w-full flex justify-between items-center py-10 font-medium hover:text-[#36A1C5] ${
isOpen ? 'text-[#36A1C5]' : 'text-gray-900'
}`}
>
{question}
<Icon size={20} />
</button>
<div
className="overflow-hidden transition-all duration-300"
style={{
height: isOpen ? contentRef.current?.scrollHeight || 'auto' : 0,
}}
>
<div
className="prose prose-lg text-base pb-10 text-[#767676]"
ref={contentRef}
dangerouslySetInnerHTML={{ __html: answer }}
/>
</div>
</li>
);
}
Full React version without TypeScript (using PropTypes)
Now, if you're not using TypeScript, you can use PropTypes instead. Below is the complete JavaScript version of the component.
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { Minus, Plus } from 'lucide-react';
export default function FAQAccordion({ faqs, defaultOpenIndex = null }) {
const [selected, setSelected] = useState(defaultOpenIndex);
return (
<ul className="">
{faqs.map((faq, index) => (
<AccordionItem
key={index}
value={index}
question={faq.question}
answer={faq.answer}
selected={selected}
setSelected={setSelected}
/>
))}
</ul>
);
}
FAQAccordion.propTypes = {
faqs: PropTypes.arrayOf(
PropTypes.shape({
question: PropTypes.string.isRequired,
answer: PropTypes.string.isRequired,
})
).isRequired,
defaultOpenIndex: PropTypes.number,
};
function AccordionItem({ value, question, answer, selected, setSelected }) {
const isOpen = selected === value;
const contentRef = useRef(null);
const Icon = isOpen ? Minus : Plus;
return (
<li className="border-b">
<button
onClick={() => setSelected(isOpen ? null : value)}
className={`w-full flex justify-between items-center py-10 font-medium hover:text-[#36A1C5] ${
isOpen ? 'text-[#36A1C5]' : 'text-gray-900'
}`}
>
{question}
<Icon size={20} />
</button>
<div
className="overflow-hidden transition-all duration-300"
style={{ height: isOpen ? contentRef.current?.scrollHeight : 0 }}
>
<div
className="prose prose-lg pb-10 text-[#767676]"
ref={contentRef}
dangerouslySetInnerHTML={{ __html: answer }}
/>
</div>
</li>
);
}
AccordionItem.propTypes = {
value: PropTypes.number.isRequired,
question: PropTypes.string.isRequired,
answer: PropTypes.string.isRequired,
selected: PropTypes.number,
setSelected: PropTypes.func.isRequired,
};
What’s next?
Now that you have a fully functional animated FAQ accordion, try customizing it. You can modify it to fetch FAQ data from an API, or experiment with different animations.