DEV Community

Zafar Alam
Zafar Alam

Posted on • Updated on

Creating Tabs component in Vue 3

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

GitHub logo zafaralam / vue3-tabs

A vue 3 tabs component example

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can access a basic Vue 3 app in you browser using http://localhost:3000/ and it should look like the below screenshot.

image

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Example 1

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>
Enter fullscreen mode Exit fullscreen mode

Example 2

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>
Enter fullscreen mode Exit fullscreen mode

Example 3

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>
Enter fullscreen mode Exit fullscreen mode

Example 4

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>
Enter fullscreen mode Exit fullscreen mode

Example 5

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>
Enter fullscreen mode Exit fullscreen mode

Example 6

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>
Enter fullscreen mode Exit fullscreen mode

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 logo zafaralam / vue3-tabs

A vue 3 tabs component example

Thanks for reading and happy coding!!!

Top comments (11)

Collapse
 
karnie6 profile image
Karthik Senthil

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?

Collapse
 
zafaralam profile image
Zafar Alam

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.

Collapse
 
zafaralam profile image
Zafar Alam

@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!!

Thread Thread
 
karnie6 profile image
Karthik Senthil

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!

Thread Thread
 
zafaralam profile image
Zafar Alam

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.

Thread Thread
 
karnie6 profile image
Karthik Senthil

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!

Thread Thread
 
karnie6 profile image
Karthik Senthil

@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?

Thread Thread
 
zafaralam profile image
Zafar Alam

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.

Thread Thread
 
karnie6 profile image
Karthik Senthil

Got it to work - and just followed you on Twitter!

Collapse
 
mreduar profile image
Eduar Bastidas

It would be great if you could make a version without ts and without scss. Not everyone uses these tools. Thanks for sharing

Collapse
 
zafaralam profile image
Zafar Alam

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.