Build an animated FAQ accordion component in React and Tailwind CSS

February 8th, 20256 mins read

Build an animated FAQ accordion component in React and Tailwind CSS

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.