Creating Tabs component in Vue 3
Tags: vue, typescript, firstpost
Published: 2021-08-01 09:42:32

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](https://modest-pare-7a44fc.netlify.app/) for the sample app.

You can access the full code of the component and sample app {% github zafaralam/vue3-tabs no-readme %}

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](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)** at the docs.

We are going to use typescript for this sample project.

```shell
$ yarn create vite tabs-example --template vue-ts
```
 Next, we are going to install the dependencies and run the project.

```shell
$ 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.

![image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9ypph6z8y54e2fby9cxj.png)

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**
```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 

```shell
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**
```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.

```html
<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 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ywurcyg4daeff2k0d6ff.png)

### Example 2

This example shows that the tab list items can be fully customized with there own icons if required.

```html
<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 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kd9dl8zaf0y4ynzytc9q.png)

### Example 3

This example shows that the tab list items can be displayed at the bottom using the **reverse** prop on the Tabs component.

```html
<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 3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/801iau7x6wvp9qimhhs7.png)

### Example 4

This example shows that the tab list can be shown vertically by using the **direction** prop on the Tabs component.

```html
<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 4](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/47ommmfsd6j9pypbilu9.png)

### 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.

```html
<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 5](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/te8emrd8w38ad7zlzocy.png)

### 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.

```html
<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>
```
![Example 6](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ytst2sggj4gwoscgbkvv.png)

**Have a look at the html in App.vue file for examples 7 and 8 for dynamically generating the tabs**


**App.vue**
```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 {% github zafaralam/vue3-tabs no-readme %}

Thanks for reading and happy coding!!!