How to Create an FAQ Accordion with VueJS

✔︎ Last updated on November 6th, 2023

Do you want to learn how to create an FAQ accordion with VueJS?

Frequently Asked Questions (FAQ) accordion is a common feature on many websites. Frameworks like Bootstrap implement FAQ accordion but most of them use jQuery.

FAQ accordion is a simple enough task that can be implemented well with vanilla JavaScript but creating it with Vue is a great starter project for Vue learners.

In this tutorial, you will learn how to implement an FAQ accordion with VueJS in a few simple steps.

If you want to take a peek at what our final project will look like, here is a demo:

<h1>FAQ Accordion in VueJs</h1>

<!-- 	Vue app -->
<div id="app">
	<faq-question v-for="(faqItem, index) in faqItems" :key="index" :faq="faqItem"> </faq-question>
</div>

<!-- added later -->
<p> Wanna know I made this? Check out
	<a href="https://csswolf.com/how-to-create-a-faq-accordion-with-vuejs/">
		this blog post.
	</a>
</p>
/* basic styles  */
* {
	box-sizing: border-box;
	font-family: system-ui;
}

body {
	padding: 30px 20px;
	max-width: 600px;
	width: 70%;
	margin: auto;
}

#app {
	font-family: system-ui;
	text-align: left;
	color: #2c3e50;
	font-size: 1em;
	padding: 20px 0px;
}

::selection {
	user-select: none;
}

/* style the FAQ section */
.question {
	background: hsl(35 10% 30% / 0.1);
	text-transform: uppercase;
	cursor: pointer;
	font-weight: bold;
	box-shadow: 0px 4px 0px 0 #88888855;
	padding: 10px 0;
	transition: transform 0.2s;
	position: relative;
}

.question:hover {
	background: hsl(35 10% 30% / 0.15);
}

.question::before {
	content: "✅";
	margin: 10px;
}

/* styles when the question is clicked */
.question:active {
	transform: translateY(4px);
	box-shadow: none;
}

.answer {
	transition: 0.25s; /* smooth slide-in */
	height: 0; /* starts collapsed */
	overflow: hidden;
	line-height: 1.5;
}

.answer::before {
	content: "👉";
	margin-right: 10px;
}

/* style the toggleIcon */
.toggleIcon {
	font-size: 1.5em;
	font-weight: bold;
	position: absolute;
	right: 20px;
	display: inline-block;
	line-height: 0.5;
	color: #666;
}

a {
	color: blue;
	text-decoration: none;
}

a:hover {
	border-bottom: 1px solid blue;
}
const app = Vue.createApp({
	data() {
		return {
			faqItems: [
				{
					q: "What is ChatGPT?",
					a:
						"ChatGPT is a language generation model developed by OpenAI. It's based on the GPT (Generative Pre-trained Transformer) architecture and is designed to generate human-like text and engage in natural-sounding conversations."
				},
				{
					q: "How does ChatGPT work?",
					a:
						"ChatGPT uses a deep learning architecture called GPT, which processes input text and generates coherent and contextually relevant responses. It's trained on a vast amount of internet text to learn grammar, context, and style to generate responses that mimic human conversation."
				},
				{
					q: "What can I use ChatGPT for?",
					a:
						"ChatGPT can be used for a variety of purposes, including drafting emails, brainstorming ideas, writing code, generating creative content, providing tutoring or information on a wide range of topics, and engaging in simulated conversations."
				}
			]
		};
	}
});

app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
       <span class="toggleIcon">
         {{ this.isOpen ? "—" : "+" }}
       </span>
    </p>
    <p class="answer" ref="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	data() {
		return {
			isOpen: false
		};
	},
	methods: {
		toggleAnswer() {
			// collapse if open, expand if not.
			if (this.isOpen) {
				this.collapse();
			} else {
				this.expand();
			}
			this.isOpen = !this.isOpen;
		},
		collapse() {
			// select the answer element
			const answer = this.$refs.answer;
			answer.style.height = 0; // set its height to 0
		},
		expand() {
			// select answer element
			const answer = this.$refs.answer;

			// set its height to its normal scroll height to expand it fully
			answer.style.height = answer.scrollHeight + "px";
		}
	}
});

app.mount("#app");

Now, without wasting any time, let’s get started.

✅ ✏️💯 The following Vue concepts have been used in this article: v-for, v-if, props, $refs, “handlebar syntax”, and components. If you are unfamiliar with any of these concepts, you can brush up on your Vue skills with some online classes.

Skeleton of the FAQ accordion

This is what our project looks like:

How to create an FAQ Accordion with VueJS.
  1. We have 3 sets of FAQs. The questions are given a class .question and the answers are given a class .answer.
  2. Initially, all the answers are hidden. Clicking on any question reveals slides down its answer.
  3. The toggle icon at the right end of every question flips between a big “+” and “-“, to indicate the visibility of the corresponding answer.

To build this app, I will use Vue 3 (options API) and code it in a Codepen, so if you want to follow along, open a new Codepen tab alongside this tutorial.

Laying things out

We will start with the basics. We need an div element in our HTML to mount our Vue app. This is what we start with:

HTML
<h1> FAQ Accordion in VueJs </h1>
<div id="app"> </div>

We also need some CSS styles to make everything look good, so here are the styles:

CSS
/* basic styles  */
* {
	box-sizing: border-box;
}

body {
	padding: 30px 20px;
	max-width: 600px;
	width: 70%;
	margin: auto;
}

#app {
	font-family: system-ui;
	text-align: left;
	color: #2c3e50;
	font-size: 1em;
	padding: 20px 0px;
}

::selection {
	user-select: none;
}

/* style the FAQ section */
.question {
	background: hsl(35 10% 30% / 0.1);
	text-transform: uppercase;
	cursor: pointer;
	font-weight: bold;
	box-shadow: 0px 4px 0px 0 #88888855;
	padding: 10px 0;
	transition: transform 0.2s;
	position: relative;
}

.question:hover {
	background: hsl(35 10% 30% / 0.15);
}

.question::before {
	content: "✅";
	margin: 10px;
}

/* styles when the question is clicked */
.question:active {
	transform: translateY(4px);
	box-shadow: none;
}


.answer {
	overflow: hidden;
	line-height: 1.5;
}

.answer::before {
	content: "👉";
	margin-right: 10px;
}

/* style the toggleIcon */
.toggleIcon {
	font-size: 1.5em;
	font-weight: bold;
	position: absolute;
	right: 20px;
	display: inline-block;
	line-height: 0.5;
	color: #666;
}

This article isn’t about the CSS, so I won’t bore you with their explanations. You can just copy and paste them into your project.

Displaying the FAQs

Next, we come to the meat of the application, which is the Vue code. Let’s understand how we are going to approach this:

We have multiple FAQs to display, we can create a Vue component and then utilize the v-for directive to generate them dynamically. To iterate over this component, we need an FAQ data array that stores the question-and-answer pairs.

Also read: CSS Margins: A Complete Guide

It is a good idea to store this array on the parent app and then pass the question-and-answer values as props to the child component. This is the JavaScript code for the parent app:

JavaScript
// app.js
const app = Vue.createApp({
	data() {
		return {
			faqItems: [
				{
					q: "What is ChatGPT?",
					a: "ChatGPT is a language generation model developed by OpenAI. It's based on the GPT (Generative Pre-trained Transformer) architecture and is designed to generate human-like text and engage in natural-sounding conversations."
				},
				{
					q: "How does ChatGPT work?",
					a:
						"ChatGPT uses a deep learning architecture called GPT, which processes input text and generates coherent and contextually relevant responses. It's trained on a vast amount of internet text to learn grammar, context, and style to generate responses that mimic human conversation."
				},
				{
					q: "What can I use ChatGPT for?",
					a:
						"ChatGPT can be used for a variety of purposes, including drafting emails, brainstorming ideas, writing code, generating creative content, providing tutoring or information on a wide range of topics, and engaging in simulated conversations."
				},
				// add more if required
			]
		};
	},
});

This is the JavaScript code for the child component that we will attach to the app.js (shown above). I call this component faq-question.

This component has an HTML template that contains two paragraphs: one for the question and one for the answer. It also has a prop through which it receives an object (from its parent) containing a question and answer of an FAQ.

JavaScript
// app.js continued..
app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question">
      {{ faq.q }}
    </p>
    <p class="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	});
	
	app.mount("#app");

You won’t yet see anything on your screen, because we haven’t yet included this component in our HTML code. We need to update the HTML code above:

HTML
<h1>FAQ Accordion in VueJs</h1>
<div id="app">
	<!-- FAQ child component -->
	<faq-question 
	    v-for="(faqItem, index) in faqItems" 
	    :key="index" 
	    :faq="faqItem"
	></faq-question>
</div>

In the above code, the third attribute (:faq) in the <faq-question> tag is how a faqItem object passed down to the child component.

Also read: CSS Padding: A Complete Guide

After writing this code, you will see the questions followed by their answers. But, at this point, this code doesn’t do anything. You can click on these questions, but they won’t reveal any answers. That’s because we haven’t yet made them listen to any events yet.

Let’s do that now.

Adding the click event listener

We will now add a click event listener to the .question in the faq-question component and add some logic to toggle the visibility of the associated answer.

Adding an event listener is easy, just add a v-on directive or its short-hand @ symbol.

JavaScript
// updated faq-question component
app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
    </p>
    <p class="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	});

We have attached the toggleAnswer method to the click event. Now we need to write logic so that clicking the question paragraph toggles the visibility of the answer.

There are two ways we can implement this functionality.

1. Toggling with v-if

One of the approaches is to simply add or remove the answer element from the DOM tree. Here’s how we can go about it: we can maintain a data property called isOpen which keeps track of the visibility of the answer. If the answer is hidden, isOpen is false otherwise, it’s true.

We can then bind the v-if directive to this property, which will render (or not render) the element based on the value of isOpen data property.

Also read: How to Create a Radar Animation Effect with CSS

When a question is clicked, we simply toggle this data property. Here’s the updated code for the faq-question component. (Updated lines are highlighted) —

JavaScript
// updated faq-question component
app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
    </p>
    <p class="answer" v-if="isOpen">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	data(){
		return {
			isOpen: false,
		}
	}, 
	methods: {
		toggleAnswer(){
			this.isOpen = !this.isOpen;
		}
	}
	});

Observe that as the isOpen data property is set to false, none of the answers are present in the DOM tree at this point. They will be added to the DOM only when a user clicks the question. This is what it looks like:

This approach works but it’s a bit jarring. The answer element is appearing and disappearing all of a sudden at the click of a button.

It is happening because the v-if directive in Vue is adding or removing the element from the DOM tree. As the answer node is being added and removed, we cannot apply any transition effect to make its appearance & disappearance smooth.

We can however take a different approach and make the answer element slide down (with a duration), so the experience is smooth for the user.

You will need to undo the changes made in this section before trying out the next approach.

2. Adding the slide-down effect

To make the slide-down effect, we need the element to be present in the DOM tree, but keep it collapsed until its sibling question is clicked.

To keep the answer collapsed, we can set its height to 0, overflow: hidden and transition : 0.25s. We need to add these lines of CSS code:

CSS
.answer {
	transition: 0.25s; /* smooth slide-in */
	height: 0;  /* starts collapsed */
	overflow: hidden; /* to prevent spillage */
}

We will again keep track of the status with the help of a data property isOpen, but this time, we won’t attach it to the v-if directive. Instead, with its help, we will toggle the height of the answer element whenever the question is clicked.

Here’s the updated code for the faq-question component:

JavaScript
app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
    </p>
    <p class="answer" ref="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	data() {
		return {
			isOpen: false
		};
	},
	methods: {
		toggleAnswer() {
			if (this.isOpen) {
				this.collapse();
			} else {
				this.expand();
			}
			// update the value of the data property
			this.isOpen = !this.isOpen;
		},
		collapse() {
			// select the answer element
			const answer = this.$refs.answer;
			answer.style.height = 0;
		},
		expand() {
			// select answer element
			const answer = this.$refs.answer;
			// set its height to its normal scroll height to expand it fully
			answer.style.height = answer.scrollHeight + "px";
		}
	}
});

Here’s an explanation of these changes:

In line 7:

  1. we have added a new attribute called ref. This attribute (provided by Vue) can be used to give individual identity to any element. It works kind of like document.getElementByID("#id") provided by JavaScript.
  2. We have given an identity of answer to this element. We can then access this element with the help of the $refs array. We need access to this element because we will change its CSS height through JavaScript.

In lines 19 to 39:

  1. We have created 3 methods: toggleAnswer, collapse and expand. The toggleAnswer simply calls one of the other methods depending on the value of isOpen. It also toggles the value of the isOpen data property.
  2. In the collapse and expand methods, we first select the answer element through $refs array and then update its height.

Here’s a working Codepen example demonstrating the code so far:

<h1>FAQ Accordion in VueJs</h1>
<div id="app">
	<!-- 	This is where Vue will inject code. -->
	<faq-question v-for="(faqItem, index) in faqItems" :key="index" :faq="faqItem"> </faq-question>
</div>
/* basic styles  */
* {
	box-sizing: border-box;
}

body {
	padding: 30px 20px;
	max-width: 600px;
	width: 70%;
	margin: auto;
}

#app {
	font-family: system-ui;
	text-align: left;
	color: #2c3e50;
	font-size: 1em;
	padding: 20px 0px;
}

::selection {
	user-select: none;
}

/* style the FAQ section */
.question {
	background: hsl(35 10% 30% / 0.1);
	text-transform: uppercase;
	cursor: pointer;
	font-weight: bold;
	box-shadow: 0px 4px 0px 0 #88888855;
	padding: 10px 0;
	transition: transform 0.2s;
	position: relative;
}

.question:hover {
	background: hsl(35 10% 30% / 0.15);
}

.question::before {
	content: "✅";
	margin: 10px;
}

/* styles when the question is clicked */
.question:active {
	transform: translateY(4px);
	box-shadow: none;
}

.answer {
	transition: 0.25s; /* smooth slide-in */
	height: 0; /* starts collapsed */
	overflow: hidden;
	line-height: 1.5;
}

.answer::before {
	content: "👉";
	margin-right: 10px;
}

/* style the toggleIcon */
.toggleIcon {
	font-size: 1.5em;
	font-weight: bold;
	position: absolute;
	right: 20px;
	display: inline-block;
	line-height: 0.5;
	color: #666;
}
const app = Vue.createApp({
	data() {
		return {
			faqItems: [
				{
					q: "What is ChatGPT?",
					a:
						"ChatGPT is a language generation model developed by OpenAI. It's based on the GPT (Generative Pre-trained Transformer) architecture and is designed to generate human-like text and engage in natural-sounding conversations."
				},
				{
					q: "How does ChatGPT work?",
					a:
						"ChatGPT uses a deep learning architecture called GPT, which processes input text and generates coherent and contextually relevant responses. It's trained on a vast amount of internet text to learn grammar, context, and style to generate responses that mimic human conversation."
				},
				{
					q: "What can I use ChatGPT for?",
					a:
						"ChatGPT can be used for a variety of purposes, including drafting emails, brainstorming ideas, writing code, generating creative content, providing tutoring or information on a wide range of topics, and engaging in simulated conversations."
				}
			]
		};
	}
});

app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
    </p>
    <p class="answer" ref="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	data() {
		return {
			isOpen: false
		};
	},
	methods: {
		toggleAnswer() {
			if (this.isOpen) {
				this.collapse();
			} else {
				this.expand();
			}
			this.isOpen = !this.isOpen;
		},
		collapse() {
			// select the answer element
			const answer = this.$refs.answer;
			answer.style.height = 0;
		},
		expand() {
			// select answer element
			const answer = this.$refs.answer;

			// set its height to its normal scroll height to expand it fully
			answer.style.height = answer.scrollHeight + "px";
		}
	}
});


app.mount("#app");

At this point, you have a working FAQ accordion, with a smooth transition. There’s just one thing left to do: adding a toggleIcon to the right side of every .question. This icon will switch between + and - at every click.

Adding a toggleIcon

Adding a toggleIcon is simple. We just have to render an HTML element whose value will toggle with the isOpen property. Here’s the updated template of the faq-question component:

JavaScript
// app.js code above 👆
// faq-question's template updated
app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
       <span class="toggleIcon">
         {{ this.isOpen ? "—" : "+" }}
       </span>
    </p>
    <p class="answer" ref="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
// remaining faq-question component's code here 👇

You don’t have to worry about styling it with CSS. We have already provided the necessary CSS declarations in our CSS code at the beginning of this article.

Here’s the final working code:

<h1>FAQ Accordion in VueJs</h1>
<div id="app">
	<!-- 	This is where Vue will inject code. -->
	<faq-question v-for="(faqItem, index) in faqItems" :key="index" :faq="faqItem"> </faq-question>
</div>
/* basic styles  */
* {
	box-sizing: border-box;
	font-family: system-ui;
}

body {
	padding: 30px 20px;
	max-width: 600px;
	width: 70%;
	margin: auto;
}

#app {
	font-family: system-ui;
	text-align: left;
	color: #2c3e50;
	font-size: 1em;
	padding: 20px 0px;
}

::selection {
	user-select: none;
}

/* style the FAQ section */
.question {
	background: hsl(35 10% 30% / 0.1);
	text-transform: uppercase;
	cursor: pointer;
	font-weight: bold;
	box-shadow: 0px 4px 0px 0 #88888855;
	padding: 10px 0;
	transition: transform 0.2s;
	position: relative;
}

.question:hover {
	background: hsl(35 10% 30% / 0.15);
}

.question::before {
	content: "✅";
	margin: 10px;
}

/* styles when the question is clicked */
.question:active {
	transform: translateY(4px);
	box-shadow: none;
}

.answer {
	transition: 0.25s; /* smooth slide-in */
	height: 0; /* starts collapsed */
	overflow: hidden;
	line-height: 1.5;
}

.answer::before {
	content: "👉";
	margin-right: 10px;
}

/* style the toggleIcon */
.toggleIcon {
	font-size: 1.5em;
	font-weight: bold;
	position: absolute;
	right: 20px;
	display: inline-block;
	line-height: 0.5;
	color: #666;
}
const app = Vue.createApp({
	data() {
		return {
			faqItems: [
				{
					q: "What is ChatGPT?",
					a:
						"ChatGPT is a language generation model developed by OpenAI. It's based on the GPT (Generative Pre-trained Transformer) architecture and is designed to generate human-like text and engage in natural-sounding conversations."
				},
				{
					q: "How does ChatGPT work?",
					a:
						"ChatGPT uses a deep learning architecture called GPT, which processes input text and generates coherent and contextually relevant responses. It's trained on a vast amount of internet text to learn grammar, context, and style to generate responses that mimic human conversation."
				},
				{
					q: "What can I use ChatGPT for?",
					a:
						"ChatGPT can be used for a variety of purposes, including drafting emails, brainstorming ideas, writing code, generating creative content, providing tutoring or information on a wide range of topics, and engaging in simulated conversations."
				}
			]
		};
	}
});

app.component("faq-question", {
	template: `
	<div class="faq">
    <p class="question" @click="toggleAnswer">
      {{ faq.q }}
       <span class="toggleIcon">
         {{ this.isOpen ? "—" : "+" }}
       </span>
    </p>
    <p class="answer" ref="answer">
      {{ faq.a }}
    </p>
  </div>
	`,
	// faq prop is passed down from app in each iteration
	props: ["faq"],
	data() {
		return {
			isOpen: false
		};
	},
	methods: {
		toggleAnswer() {
			// collapse if open, expand if not.
			if (this.isOpen) {
				this.collapse();
			} else {
				this.expand();
			}
			this.isOpen = !this.isOpen;
		},
		collapse() {
			// select the answer element
			const answer = this.$refs.answer;
			answer.style.height = 0; // set its height to 0
		},
		expand() {
			// select answer element
			const answer = this.$refs.answer;

			// set its height to its normal scroll height to expand it fully
			answer.style.height = answer.scrollHeight + "px";
		}
	}
});

app.mount("#app");

Analyze the code and play around with it.

Final words

Creating an FAQ accordion in VueJs is pretty easy if you understand a few basic concepts. It’s amazing to see how much you can accomplish with how little knowledge of Vue.

We used only a few basic Vue-specific directives and elements in this code, namely v-for, $refs, Vue component and its template.

If you learn the concepts used in this article, you can already build some awesome projects.

If you loved (💘) this article, please (🙏) share it with your friends.