Component design is one of the most crucial skills you can pick up as a Vue developer. However, designing a reusable, scalable, and generic component can be a little challenging, and this problem only gets compounded when designing nested components.
The good news?
In this tutorial, I'll help you get past all of that, by walking you through the step-by-step process for designing a tab component.
Huzzah!
So let's dive into it.
This tutorial assumes you're already familiar with the basics of Vue.
For a basic project, you can follow the instructions for new projects here.
Alternatively, you can use StackBlitz to create a new Vue project on the web.
You don't need to install packages like Pinia or the Vue Router. A reasonably minimal project is all that's required.
Feel free to check out this demo for what we'll be building.
Note: Examples of this component are written with the Options API and composition API.
The first step is to create the two components:
It's always important to consider the responsibility of each component before you create it.
For example
Whenever you're designing nested components, the wrapping component is typically responsible for managing child components, whereas individual child components are responsible for rendering content.
As mentioned earlier, I'll show you how to design a tab component with the options API and composition API, but you can simply follow the examples that suit the API that you'd like to use in your project.
Inside the TabsOptions.vue/TabsComposition.vue component, we'll render the following template:
<template>
<div>
<ul>
<li>Tab Title</li>
</ul>
<slot />
</div>
</template>
The <slot>
component is incredibly helpful for rendering content. We'll be using it to render individual tabs inserted into the component's tags.
Next, add a template for the TabOptions.vue/TabComposition.vue components.
<template>
<div>
<slot />
</div>
</template>
Similar to the container, individual tabs can render content with the <slot>
component.
Lastly, we can update the App.vue component.
<template>
<div>
<h1>Options API</h1>
<TabsOptions>
<TabOptions title="Tab 1">Tab 1 Content</TabOptions>
<TabOptions title="Tab 2">Tab 2 Content</TabOptions>
<TabOptions title="Tab 3">Tab 3 Content</TabOptions>
<TabOptions title="Tab 4">Tab 4 Content</TabOptions>
</TabsOptions>
<hr />
<h1>Composition API</h1>
<TabsComposition>
<TabComposition title="Tab 1">Tab 1 Content</TabComposition>
<TabComposition title="Tab 2">Tab 2 Content</TabComposition>
<TabComposition title="Tab 3">Tab 3 Content</TabComposition>
<TabComposition title="Tab 4">Tab 4 Content</TabComposition>
</TabsComposition>
</div>
</template>
<script>
import TabsOptions from "@/components/TabsOptions.vue";
import TabOptions from "@/components/TabOptions.vue";
import TabsComposition from "@/components/TabsComposition.vue";
import TabComposition from "@/components/TabComposition.vue";
export default {
components: {
TabsOptions,
TabOptions,
TabsComposition,
TabComposition,
},
};
</script>
So let's have a look at whats happening here.
In the above snippet, we're registering the components. (Once again, if you're only interested in a specific API, you don't need to worry about the other components relative to the API you're not using).
In addition to registering the components, we're also using them from within the template to render dummy content.
How?
Well, on the individual tab components, a custom prop called title
has been added. The issue of course is that our components don't accept this prop, but they will in the future. We're adding them now just so that we don't have to revisit the App.vue component.
The container is going to be responsible for keeping track of the active tab. (Traditionally, tabs only display content from one tab).
Our job is to keep track of which tab to display, and to accomplish this, we must store two pieces of information as data.
If you're using the options API, add the data()
function, with two properties called activeTabHash
and tabs
in the TabsOptions.vue component.
<script>
export default {
data() {
return {
activeTabHash: "",
tabs: [],
};
},
};
</script>
The active tab will be stored as a string, and by default, none of the tabs will be active since the component isn't going to assume there's tab content.
If you're using the composition API, you can create the activeTabHash
and tabs
properties by using the ref()
function in the TabsComposition.vue component.
<script setup>
import { ref } from "vue";
const activeTabHash = ref("");
const tabs = ref([]);
</script>
Displaying content is the responsibility of individual tabs, but there's just one problem - how does a tab know when to display content?
Well, the parent component has the activeTabHash
data property to keep track of this information, and we simply pass on this information to each tab.
For this component, we'll be using the provide/inject API. This API allows components to pass on functions and variables to child components.
In the TabsOptions.vue component, we can add the provide()
function.
import { computed } from "vue";
export default {
// Some code here...
provide() {
return {
addTab: (tab) => {
const count = this.tabs.push(tab);
if (count === 1) {
this.activeTabHash = tab.hash;
}
},
activeTabHash: computed(() => this.activeTabHash),
};
},
};
A few key points are worth mentioning.
provide()
function must be defined as a regular function. Otherwise, you won't have access to the component instance's data. (i.e.: the this
keyword)provide()
function with the data we'd like to expose to child componentsaddTab
. An object called tab
is accepted, which is pushed into the tabs
array of the componentThere's one portion of the code that I want to focus on. Specifically, this line of code.
activeTabHash: computed(() => this.activeTabHash);
It's a strange syntax that you may not have encountered before if you don't use the composition API, so let me explain.
By default, values in the object returned by the provide()
function are not reactive. This means if the activeTabHash
data property were updated, child components would not be notified of this update.
In most cases though, you'll likely want your data to be reactive for child components. We can achieve this by borrowing the computed()
function from Vue, designed for the composition API, and use it to make our data reactive.
import { computed } from "vue";
All you have to do is wrap your data property with this function in an arrow function, and you're good to go!
However, things are different in the TabsComposition.vue component for those of you who are using the composition API.
import { ref, provide } from "vue";
const activeTabHash = ref("");
const tabs = ref([]);
provide("addTab", (tab) => {
const count = tabs.value.push(tab);
if (count === 1) {
activeTabHash.value = tab.hash;
}
});
provide("activeTabHash", activeTabHash);
We can import the provide
function from the vue
package to expose the same data and functions to child components.
The function for the composition API is the exact same as the function for the options API, but there are two arguments that must be provided to the provide
function.
Unlike the options API, we don't have to wrap the activeTabHash
variable with the computed
function. This is because it's already made reactive with the ref
function.
In the templates of the TabsOptions.vue/TabsComposition.vue, we can update the <li>
elements by looping through the tabs
array.
<ul>
<li v-for="tab in tabs" :key="tab.title" @click="activeTabHash = tab.hash">
{{ tab.title }}
</li>
</ul>
Binding the key
attribute to the tab.title
property will help Vue associate each item in the array with an <li>
element. Then, from within the element, we're rendering the title with an expression.
Lastly, we're listening to the click
event to update the active tab.
This means that if the user clicks on a tab, the activeTabHash
property should be updated to the tab's hash, which we'll add soon.
Time to work on the individual tab components.
First, we must prepare the data in the TabOptions.vue component.
<script>
export default {
data() {
return { hash: "", isActive: false };
},
};
</script>
In the data()
function, we're creating two properties called hash
and isActive
.
The hash
property will contain a unique ID for the tab, whereas the isActive
property will handle toggling the tab's content (This means that initially, the content will be hidden).
In the TabComposition.vue component, we can store the same information with the ref()
function.
<script setup>
import { ref } from "vue";
const hash = ref("");
const isActive = ref(false);
</script>
Component data is not the only thing we need to prepare though.
Both tab components accept the title, and so in the TabOptions.vue component, we can add the props
object to accept the title
prop.
export default {
props: {
title: {
type: String,
required: true,
},
},
// Some code here...
};
Important: The
title
prop will be required. Otherwise, Vue will throw an error.
Luckily, we've already prepared titles for each tab in the App.vue component.
In the TabComposition.vue component, we can use the defineProps
function to accept props. An array of props must be passed in, and then the props are returned as an object.
const props = defineProps(["title"]);
The parent component exposes a function and data property to child tab components. However, they're not automatically available to our component, and so we must manually accept them.
In the TabOptions.vue component, we can add a property called inject
to accept values provided by parent components.
In the example below, we're accepting the addTab
and activeTabHash
values.
export default {
inject: ["addTab", "activeTabHash"],
};
For the composition API, we can import the inject()
function to accept the same values.
import { inject } from "vue";
const addTab = inject("addTab");
const activeTabHash = inject("activeTabHash");
Now that we have our data, we must start registering a tab.
At the moment, the parent component is unaware that we have a tab because the tabs
array is left unfilled. But by injecting the addTab
function, we'll be able to update this array from our component.
For the options API, we'll register the tab during the created()
lifecycle.
export default {
created() {
this.hash = `#${this.title.toLowerCase().replace(/ /g, "-")}`;
this.addTab({
title: this.title,
hash: this.hash,
});
},
watch: {
activeTabHash() {
this.isActive = this.activeTabHash === this.hash;
},
},
};
First, we're updating the hash
property by converting the title to a hash that we can use in a URL. Keep in mind, your titles should be unique. Otherwise, your hashes won't be unique either.
Next, we're calling the addTab
function to register the tab. Two pieces of information are provided, which are the title and hash.
Lastly, we're watching the activeTabHash
property for changes. If it changes, we're comparing the active hash with the hash from the current tab. If they're equal, the isActive
property will be set to true
.
(In the composition API, we can replicate the same behavior by importing the onBeforeMount
and watch
functions).
import { onBeforeMount, watch } from "vue";
onBeforeMount(() => {
hash.value = `#${props.title.toLowerCase().replace(/ /g, "-")}`;
addTab({
title: props.title,
hash: hash.value,
});
});
watch(activeTabHash, () => {
isActive.value = activeTabHash.value === hash.value;
});
The onBeforeMount()
function can be considered the next closest hook to the created()
hook for the options API.
The last step for both components of the options API and composition API is to toggle the content's appearance with the v-show
directive.
<template>
<div v-show="isActive">
<slot></slot>
</div>
</template>
And that's it! Now you should be able to click on any of the tabs to toggle content.
You can apply CSS to your components and customize them any way you'd like. But if you want to make things easier, consider using Tailwind.
I've provided a design you can use in your own projects if you'd like.
Check out this guide for installing Tailwind with your project. It works with both local and StackBlitz projects.
For the TabsOptions.vue/TabsComposition.vue components, you can use the following classes:
<template>
<div class="border-4 border-black rounded">
<ul class="flex flex-nowrap justify-between">
<li
class="w-full font-black text-center py-4 cursor-pointer border-b-4 border-black"
:class="{
'bg-yellow-50': tab.hash !== activeTabHash,
'bg-lime-200': tab.hash === activeTabHash,
}"
v-for="tab in tabs"
:key="tab.title"
@click="activeTabHash = tab.hash"
>
{{ tab.title }}
</li>
</ul>
<slot />
</div>
</template>
For the TabOptions.vue/TabComposition.vue component, You can add simple padding like so.
<template>
<div class="p-8" v-show="isActive">
<slot />
</div>
</template>
The biggest challenge in designing nested components is sharing data. Thanks to Vue's provide/inject API, this process is simplified.
provide()
function and inject values by adding the inject
arrayprovide()
and inject()
functions into your setupThe key thing to understand is that the behavior is all the same, and I hope this tutorial helped you understand how to design nested components in Vue a little better!
If you want to learn much more about Vue, as well as all thew new Vue 3 features, then check out my complete Vue Developer course.
You can even start taking the course for free (no credit card or signup required) using this link.
It's the only Vue.js tutorial + projects course you need to learn Vue (including all new Vue 3 features), build large-scale Vue applications from scratch, and get hired as a Vue developer this year.
P.S... Want to build a Vue project to make your portfolio stand out?
Check out my new Vue image filters app project (Using Vue, Typescript, and WebAssembly), live over in the ZTM workshops.