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.


Intersted in seeing some videos? Check out my recent uploads.

What Are MCPs? I Used Cursor to Create a Repo and Push Code to GitHub
25:55

October 8th, 2025

What Are MCPs? I Used Cursor to Create a Repo and Push Code to GitHub

Understanding Docker Multi-Stage Builds (with Example) - Fast and Efficient Dockerfiles
21:26

September 1st, 2025

Understanding Docker Multi-Stage Builds (with Example) - Fast and Efficient Dockerfiles

3 Tips to Optimize Docker Images | Reduce Size, Improve Performance & Security
19:04

August 22nd, 2025

3 Tips to Optimize Docker Images | Reduce Size, Improve Performance & Security

Passwordless Auth in Node.js with Magic Links (Step-by-Step)
26:47

July 7th, 2025

Passwordless Auth in Node.js with Magic Links (Step-by-Step)

One Security Check Most Devs Forget in Their Signup Flow
10:11

May 20th, 2025

One Security Check Most Devs Forget in Their Signup Flow

How to Implement OAuth in Your Node.js Backend (GitHub & Google Login)
30:03

May 15th, 2025

How to Implement OAuth in Your Node.js Backend (GitHub & Google Login)

Next.js JWT Auth Explained: Cookies, Refresh Tokens & 2FA (No Auth.js)
01:02:51

May 6th, 2025

Next.js JWT Auth Explained: Cookies, Refresh Tokens & 2FA (No Auth.js)

Add 2FA to Your Node.js App with otplib
44:19

April 6th, 2025

Add 2FA to Your Node.js App with otplib

How to Run PostgreSQL with Docker Locally
25:48

March 31st, 2025

How to Run PostgreSQL with Docker Locally

Node.js Auth API with JWT, PostgreSQL & Prisma | Vibe Coding
01:23:24

March 30th, 2025

Node.js Auth API with JWT, PostgreSQL & Prisma | Vibe Coding

Create Stunning Emails in React — React-Email & Resend
33:53

March 6th, 2025

Create Stunning Emails in React — React-Email & Resend

Complete Guide — React Internationalization (i18n) with i18next
56:15

February 17th, 2025

Complete Guide — React Internationalization (i18n) with i18next

Build an Animated FAQ Accordion Component in React and Tailwind CSS
30:25

February 8th, 2025

Build an Animated FAQ Accordion Component in React and Tailwind CSS

Learn Shell Scripting With Me Using ChatGPT
47:12

January 30th, 2025

Learn Shell Scripting With Me Using ChatGPT

How to Build a Mega Menu in Next.js – Step-by-Step Tutorial
36:33

January 20th, 2025

How to Build a Mega Menu in Next.js – Step-by-Step Tutorial

The Easiest Way to Make Your Footer Stick to the Bottom || CSS Grid or Flexbox
11:00

January 13th, 2025

The Easiest Way to Make Your Footer Stick to the Bottom || CSS Grid or Flexbox

Form Validation in Next.js with React Hook Form & Zod
27:47

October 31st, 2024

Form Validation in Next.js with React Hook Form & Zod

Styling Active Links in Next.js
08:31

January 20th, 2024

Styling Active Links in Next.js

What is Bun — A JavaScript All-in-one Toolkit
29:01

January 17th, 2024

What is Bun — A JavaScript All-in-one Toolkit

Copy and Paste with JavaScript — 7 lines of code 🔥
01:00

December 27th, 2023

Copy and Paste with JavaScript — 7 lines of code 🔥

Migrate a Node.js Project to use Bun — 60 seconds ⚡️
01:00

December 25th, 2023

Migrate a Node.js Project to use Bun — 60 seconds ⚡️

Build a Slackbot With Slack API and Node.js
38:16

December 19th, 2023

Build a Slackbot With Slack API and Node.js

Technical Writing & Technical Blogging [w/ Asaolu Elijah]
17:58

December 30th, 2020

Technical Writing & Technical Blogging [w/ Asaolu Elijah]