Hi! In this post we are going to walk through how to create a Tabs component in Vue 3.
The main aim for this post is for me to get started with writing posts and giving back to the wonderful open source community. If you find this helpful please share and like the post. Also please send you feedback on what could be improved for future posts.
You can access the Demo for the sample app.
You can access the full code of the component and sample app
Enough of small talk, lets get to business. We are going to start with creating a blank project using Vite for Vue 3 project. You can read more about getting started with Vite at the docs.
We are going to use typescript for this sample project.
$ yarn create vite tabs-example --template vue-ts
Next, we are going to install the dependencies and run the project.
$ yarn
# once the above command completes run the project with the below command
$yarn dev
You can access a basic Vue 3 app in you browser using http://localhost:3000/
and it should look like the below screenshot.
Your project folder structure should look.
├───node_modules
├───public
│ └───favicon.ico
├───src
│ ├───App.vue
│ ├───main.ts
│ ├───shims-vue.d.ts
│ ├───vite-env.d.ts
│ ├───assets
│ │ └──logo.png
│ └───components
│ └──HelloWorld.vue
├───.gitignore
├───index.html
├───package.json
├───README.md
├───tsconfig.json
├───vite.config.js
└───yarn.lock
Next, we will remove all the code within the App.vue file under the src folder and replace it with the below.
App.vue
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "App",
components: {},
});
</script>
<template>
<div class="tabs-example">
<h1>This is a <b>Tabs</b> example project with Vue 3 and Typescript</h1>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
Now, we can create a new file under the src/components folder called Tabs.vue. We are going to use scss for our styles so we need a sass
dependency for our project. You can install it by
yarn add sass
Note: you will need to stop and start the dev server again yarn dev
Now add the following code to the Tabs.vue file we created earlier.
The component also registers an event listener for keyboard events and can tabs can be changed using Ctrl + [Tab number]
e.g.Ctrl + 1
Tabs.vue
<script lang="ts">
import {
defineComponent,
onMounted,
onBeforeUnmount,
ref,
watch,
toRefs,
h,
VNode,
computed,
onBeforeUpdate,
} from "vue";
interface IProps {
defaultIndex: number;
resetTabs: boolean;
position: string;
direction: string;
reverse: boolean;
}
export default defineComponent({
name: "Tabs",
props: {
defaultIndex: {
default: 0,
type: Number,
},
resetTabs: {
type: Boolean,
default: false,
},
direction: {
type: String,
default: "horizontal",
validator(value: string) {
return ["horizontal", "vertical"].includes(value);
},
},
position: {
type: String,
default: "left",
validator(value: string) {
return ["left", "start", "end", "center"].includes(value);
},
},
reverse: {
type: Boolean,
required: false,
default: false,
},
},
emits: {
tabChanged(index: number) {
return index !== undefined || index !== null;
},
},
setup(props: IProps, { emit, slots, attrs }) {
const { defaultIndex, resetTabs, position, direction, reverse } =
toRefs(props);
const selectedIndex = ref(0);
const tabs = ref<Array<any>>([]);
const _tabItems = ref<any[]>([]);
const onTabKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
if (parseInt(e.key) - 1 in tabs.value) {
e.preventDefault();
switchTab(e, parseInt(e.key) - 1, tabs.value[parseInt(e.key) - 1]);
}
}
};
const reset = () => {
selectedIndex.value = 0;
};
const switchTab = (_: any, index: number, isDisabled: boolean) => {
if (!isDisabled) {
selectedIndex.value = index;
emit("tabChanged", index);
}
};
onMounted(() => {
getTabItems();
document.addEventListener("keydown", onTabKeyDown);
});
onBeforeUnmount(() => {
document.removeEventListener("keydown", onTabKeyDown);
});
watch(defaultIndex, (newValue, oldValue) => {
if (newValue !== selectedIndex.value) {
selectedIndex.value = newValue;
}
});
watch(resetTabs, (newValue, oldValue) => {
if (newValue === true) reset();
});
onBeforeUpdate(() => {
getTabItems();
});
const getTabItems = () => {
_tabItems.value.splice(0, _tabItems.value.length);
(slots as any).default().forEach((component: any) => {
if (component.type.name && component.type.name === "Tab") {
_tabItems.value.push(component);
} else {
component.children.forEach((cComp: any) => {
if (cComp.type.name && cComp.type.name === "Tab") {
_tabItems.value.push(cComp);
}
});
}
});
};
const getTitleSlotContent = (titleSlot: string): any => {
let slotContent: any = null;
let shouldSkip = false;
(slots as any).default().forEach((item: any) => {
if (shouldSkip) {
return;
}
if (item.type === "template" && item.props.name === titleSlot) {
slotContent = item.children;
shouldSkip = true;
} else {
if (item.children.length) {
item.children.forEach((cItem: any) => {
if (shouldSkip) {
return;
}
if (cItem.props.name === titleSlot) {
slotContent = cItem.children;
shouldSkip = true;
}
});
}
}
});
return slotContent === null ? [] : slotContent;
};
const tabToDisplay = computed(() => {
return _tabItems.value.map((item, idx) => {
return h(
"div",
{
class: "tab",
style: `display: ${selectedIndex.value == idx ? "block" : "none"}`,
},
item
);
});
// return h("div", { class: "tab" }, _tabItems.value[selectedIndex.value]);
});
return () => {
const tabList: Array<VNode> = [];
_tabItems.value.forEach((tab: VNode, index: number) => {
const _tabProps = tab.props as {
title?: string;
"title-slot"?: string;
disabled?: boolean | string;
};
const titleContent = _tabProps["title-slot"]
? getTitleSlotContent(_tabProps["title-slot"])
: _tabProps.title;
const isDisabled =
_tabProps.disabled === true || _tabProps.disabled === "";
tabs.value[index] = isDisabled;
tabList.push(
h(
"li",
{
class: "tab-list__item",
tabIndex: "0",
role: "tabItem",
"aria-selected": selectedIndex.value === index ? "true" : "false",
"aria-disabled": isDisabled ? "true" : "false",
onClick: (e: MouseEvent) => {
switchTab(e, index, isDisabled);
},
},
titleContent
)
);
});
return h(
"div",
{
class: `tabs ${direction.value} ${reverse.value ? "reverse" : ""}`,
role: "tabs",
},
[
h(
"ul",
{ class: `tab-list ${position.value}`, role: "tabList" },
tabList
),
...tabToDisplay.value,
]
);
};
},
});
</script>
<style lang="scss">
:root {
--primary-color: #4313aa;
--border-color: #e2e2e2;
--disabled-text-color: #999;
}
.tabs {
display: grid;
grid-template-columns: 1fr;
.tab-list {
list-style: none;
display: flex;
padding-left: 0;
border-bottom: 1px solid var(--border-color);
&.center {
justify-content: center;
}
&.end {
justify-content: flex-end;
}
&__item {
padding: 8px 10px;
cursor: pointer;
user-select: none;
transition: border 0.3s ease-in-out;
position: relative;
bottom: -1px;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.05rem;
&:not(:first-child) {
margin-left: 10px;
}
&[aria-selected="true"] {
border-bottom: 2px solid var(--primary-color);
font-weight: 700;
color: var(--primary-color);
}
&[aria-disabled="true"] {
cursor: not-allowed;
color: var(--disabled-text-color);
}
}
}
&.horizontal {
&.reverse {
.tab-list {
grid-row: 2;
border: none;
border-top: 1px solid var(--border-color);
}
}
}
&.vertical {
grid-template-columns: auto 1fr;
gap: 1rem;
.tab-list {
flex-direction: column;
border-bottom: none;
border-right: 1px solid var(--border-color);
&__item {
margin-left: 0;
border-radius: 0;
&[aria-selected="true"] {
border: none;
border-left: 2px solid var(--primary-color);
}
}
}
&.reverse {
grid-template-columns: 1fr auto;
.tab-list {
grid-column: 2;
border: none;
border-left: 1px solid var(--border-color);
}
.tab {
grid-row: 1;
grid-column: 1;
}
}
}
}
</style>
Next we are going to use our newly created components. All examples can be see in the App.vue file. Here I'm going to show you some example use cases.
Example 1
This is the most basic way to use the Tabs component. The tab list will be show at the top and the names of the tabs are derived from the title prop of each Tab component.
<tabs>
<tab title="Tab 1">
<h3>This is Tab 1</h3>
</tab>
<tab title="Tab 2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Example 2
This example shows that the tab list items can be fully customized with there own icons if required.
<tabs>
<template name="config">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<tab title-slot="config">
<h3>This is a config tab</h3>
</tab>
<tab title="Tab 2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Example 3
This example shows that the tab list items can be displayed at the bottom using the reverse prop on the Tabs component.
<tabs reverse>
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Example 4
This example shows that the tab list can be shown vertically by using the direction prop on the Tabs component.
<tabs direction="vertical">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Example 5
This example shows that the tab list can be shown in the center or end by using the position prop on the Tabs component.
<tabs position="center">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Example 6
This example shows that the tab list can be shown in the center or end by using the position prop on the Tabs component.
<tabs position="end">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
Have a look at the html in App.vue file for examples 7 and 8 for dynamically generating the tabs
App.vue
<script lang="ts">
import { defineComponent } from "vue";
import Tabs from "./components/Tabs.vue";
import Tab from "./components/Tab.vue";
export default defineComponent({
name: "App",
components: { Tabs, Tab },
});
</script>
<template>
<h1>This is a <b>Tabs</b> example project with Vue 3 and Typescript</h1>
<div class="tabs-example">
<div class="example example-1">
<h2>Example 1</h2>
<p>
This is the most basic way to use the Tabs component. The tab list will
be show at the top and the names of the tabs are derived from the title
prop of each Tab component.
</p>
<tabs class="Tab-exp1">
<tab title="Tab 1">
<h3>This is Tab 1</h3>
</tab>
<tab title="Tab 2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-2">
<h2>Example 2</h2>
<p>
This example shows that the tab list items can be fully customized with
there own icons if required.
</p>
<tabs>
<template name="config">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<tab title-slot="config">
<h3>This is a config tab</h3>
</tab>
<tab title="Tab 2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-3">
<h2>Example 3</h2>
<p>
This example shows that the tab list items can be displayed at the
bottom using the <b>reverse</b> prop on the Tabs component.
</p>
<tabs reverse>
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-4">
<h2>Example 4</h2>
<p>
This example shows that the tab list can be shown vertically by using
the <b>direction</b> prop on the Tabs component.
</p>
<tabs direction="vertical">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-5">
<h2>Example 5</h2>
<p>
This example shows that the tab list can be shown in the center or end
by using the <b>position</b> prop on the Tabs component.
</p>
<tabs position="center">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-6">
<h2>Example 6</h2>
<p>
This example shows that the tab list can be shown in the center or end
by using the <b>position</b> prop on the Tabs component.
</p>
<tabs position="end">
<template name="tab1">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Config
</div>
</template>
<template name="tab2">
<div class="tab-title">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab 2
</div>
</template>
<tab title-slot="tab1">
<h3>This is a config tab</h3>
</tab>
<tab title-slot="tab2">
<h3>This is Tab 2</h3>
</tab>
</tabs>
</div>
<div class="example example-7">
<h2>Example 7</h2>
<p>
This example shows a list of tabs generated from an array. This can be
used to dynamically generate the tabs
</p>
<tabs>
<tab v-for="(i, idx) in dynamicTabs" :key="idx" :title="`Tab ${i}`">
<h3>This is Tab {{ i }}</h3>
</tab>
</tabs>
</div>
<div class="example example-8">
<h2>Example 8</h2>
<p>
This example shows a list of tabs generated from an array. This can be
used to dynamically generate the tabs
</p>
<tabs>
<template v-for="(i, idx) in dynamicTabs" :key="idx">
<div class="tab-title" :name="`tab-exp7-${i}`">
<i class="ri-settings-3-fill" aria-hidden="true"></i>
Tab {{ i }}
</div>
</template>
<tab
v-for="(i, idx) in dynamicTabs"
:key="idx"
:title-slot="`tab-exp7-${i}`"
>
<h3>This is Tab {{ i }}</h3>
</tab>
</tabs>
</div>
</div>
</template>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
text-align: center;
margin-top: 4px;
}
.tabs-example {
display: grid;
place-items: center;
text-align: left;
.example {
width: 80%;
padding: 0 1rem;
border-radius: 8px;
background: #fdfdff;
border: 2px solid #e7e7f5;
margin-block-end: 1rem;
}
}
</style>
As you can see the component can be used in a multitude of ways depending on the need of your app.
I know that the component can be improved and more functionality can be added or improved, so please send in your feedback. Also I will be packaging this component so you can directly use it your own apps without having to write it yourself but I wanted to show you a way of creating dynamic components for your app.
You can access the full code of the component and sample app
Thanks for reading and happy coding!!!
Top comments (11)
Hi @zafaralam - this is great. When using this, I'm having difficulty rendering a dynamic list of (e.g. i'm using . It seems that does its magic at a different part of the lifecycle and doesn't recompute - as a result, I don't see any tabs. Any ideas on this?
Thanks @karnie6
I did realise a little bug myself. I'll update the code in the repo and the blog post to reflect the change.
@karnie6 I just realized that you were trying to render a dynamic list. In the Tabs.vue file you should see the onMounted method loads the tabs list. You will need some way to modify that, such as using a watcher.
If you post an example in the issues for the repo, I could help a bit more.
Good luck!!
Hi @zafaralam - appreciate the quick response! I'll take a look at the onMounted and see if i can run the same code for onUpdated. I also realized my initial post didnt have much detail.
What I'm trying to do is iterate through an array and dynamically render elements under , one for each item in the array. Here's an example using a fixed array (
testArray
of 2 elements): github.com/karnie6/helpdesk-nft-ar...When I run this, I don't see any tabs render. Inspecting the HTML, you see the HTML from the element but the HTML that's supposed to be generated from the element isn't there. However, if I hardcode two elements, it works. I agree with your suspicion it's a lifecycle issue of some sort.
Appreciate any help you can provide!
Hi @karnie6 I've updated the tabs component and it should now render the tabs dynamically. Look at the examples 7 & 8 in the App.vue file for how you can use it.
Hope this helps and thanks again for providing an example.
hey @zafaralam - AMAZING! Looks like it works, thanks so much for the quick fix!
FYI, I'd love to share what I'm building with you - if you're interested, wanna email me at karthik dot senthil at gmail ? Thanks!
@zafaralam - one other question. If i wanted to refresh the tab content on tab click, what's the best way to do that? Does have to listen to the 'tabChanged' event and do a force refresh? If so, what way would you recommend implementing that?
I would hook into the tabChanged event and then write my logic in the component that has the tabs in it.
This way you will be able to better control the refresh behaviour and it will be decoupled from the tabs component.
Also my email is syed.alam@live.com.au or you can reach out to me on twitter @zafalam.
Got it to work - and just followed you on Twitter!
It would be great if you could make a version without ts and without scss. Not everyone uses these tools. Thanks for sharing
I've been thinking about making version without ts and scss but just haven't got around to it. You can use most of the TS code as JS and just remove the typing. For scss to CSS you can use an online converter.
I'll try to create a version but no promises.