Skip to content

Tabs Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • Optional: i18n library if using translation keys

Full Component Code ​

vue
<script setup>
import { toRefs, ref, nextTick, onMounted, watch, inject } from "vue";

const convertPixelsToRem = inject('convertPixelsToRem', null);

const props = defineProps({
  tabItems: {
    type: Array,
    required: false,
    default: () => [],
  },
  selected: {
    type: [Array, String],
    required: false,
    default: null,
  },
  multiple: {
    type: Boolean,
    required: false,
    default: false,
  },
  isLabeli18String: {
    type: Boolean,
    required: false,
    default: true,
  },
  singlePacked: {
    type: Boolean,
    required: false,
    default: false,
  },
  tabClasses: {
    type: Array,
    required: false,
    default: () => [],
  },
  bottomLineStyle: {
    type: Boolean,
    required: false,
    default: false,
  },
});

const { tabItems, selected, multiple, bottomLineStyle } = toRefs(props);
const emit = defineEmits(["item:clicked"]);

// Refs for bottom line animation
const tabsContainer = ref(null);
const bottomLine = ref(null);

function handleTabItemClick(item) {
  emit("item:clicked", item);
  if (bottomLineStyle.value) {
    nextTick(() => {
      updateBottomLinePosition();
    });
  }
}

function updateBottomLinePosition() {
  if (!bottomLineStyle.value || !tabsContainer.value || !bottomLine.value) return;
  
  const selectedTab = tabsContainer.value.querySelector('.tab.selected');
  if (selectedTab) {
    const containerRect = tabsContainer.value.getBoundingClientRect();
    const tabRect = selectedTab.getBoundingClientRect();
    const offsetLeft = tabRect.left - containerRect.left;
    const width = tabRect.width;
    
    bottomLine.value.style.transform = `translateX(${convertPixelsToRem(offsetLeft)}rem)`;
    bottomLine.value.style.width = `${convertPixelsToRem(width)}rem`;
  }
}

onMounted(() => {
  if (bottomLineStyle.value) {
    nextTick(() => {
      updateBottomLinePosition();
    });
  }
});

watch(selected, () => {
  if (bottomLineStyle.value) {
    nextTick(() => {
      updateBottomLinePosition();
    });
  }
});
</script>

<template>
  <div class="basic-tabs-wrapper">
    <div 
      ref="tabsContainer"
      class="overflow-container" 
      :class="[{ 
        'single-packed': singlePacked,
        'bottom-line-style': bottomLineStyle 
      }, ...tabClasses]"
    >
      <div
        v-for="item of tabItems"
        :key="item.value"
        class="tab"
        :class="[{
          selected: multiple
            ? selected.includes(item.value)
            : selected === item.value,
        }]"
        @click="handleTabItemClick(item)"
      >
        <slot name="prepend" :item="item">
          <div v-if="item && item.prepend" class="prepend-icon" v-html="item.prepend"></div>
        </slot>
        <slot name="tab-icon" :item="item">
          <template v-if="isLabeli18String && item.name">
            {{ $t(item.name) }}
          </template>
          <template v-else-if="item.name">
            {{ item.name }}
          </template>
          <div class="icon" v-else-if="item.icon" v-html="item.icon"></div>
        </slot>
        <slot name="append" :item="item">
          <div v-if="item && item.append" class="append-icon" v-html="item.append"></div>
        </slot>
      </div>
      <!-- Moving bottom line for bottomLineStyle -->
      <div class="bottom-line-container" v-if="bottomLineStyle">
        <div 
          ref="bottomLine"
          class="bottom-line"
        ></div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.basic-tabs-wrapper {
  display: flex;
  position: relative;
  overflow-x: hidden;
  width: 100%;
  .overflow-container {
    display: flex;
    align-items: center;
    justify-content: start;
    overflow: hidden;
    overflow-x: auto;
    gap: 0.5rem;
    &:has(.icon) {
      width: max-content;
      border-radius: 0.25rem;
    }
    .tab {
      cursor: pointer;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 0 0.75rem;
      border-radius: 0.25rem;
      border: 0.0625rem solid black;
      background: white;
      color: black;
      font-size: 0.8125rem;
      font-weight: 500;
      min-width: 3.375rem;
      height: 2rem;
      transition: all 0.3s ease;
      text-transform: capitalize;
      &:hover,
      &.selected {
        color: white;
        border-color: black;
        background: black;
      }
      &:has(.icon) {
        padding: 0 0.5rem;
        .icon {
          display: flex;
          justify-content: center;
          align-items: center;
          width: 1.25rem;
          height: 1.25rem;
        }
      }
    }
    &.single-packed {
      border: 0.0625rem solid black;
      border-radius: 0.25rem;
      gap: 0rem;
      .tab {
        border-radius: 0;
        border: none;
        &:not(:last-child) {
          border-right: 0.0625rem solid black;
        }
      }
    }
    &.dark-gold-tab {
      .tab {
        background: #f7dd19;
        color: black;
        border-color: #f7dd19;
        &:hover, &.selected {
          background: black;
          color: #f7dd19;
        }
      }
    }
    &.bottom-line-style {
      position: relative;
      gap: 0;      
      .tab {
        border: none;
        border-radius: 0;
        background: transparent;
        color: #666;
        font-weight: 500;
        padding: 0.75rem 1.5rem;
        position: relative;
        transition: color 0.3s ease;
        
        &:hover {
          background: transparent;
          color: #333;
          border-color: transparent;
        }
        
        &.selected {
          background: transparent;
          color: #007bff;
          border-color: transparent;
        }
      }
      .bottom-line-container {
        position: absolute;
        width: 100%;
        height: 2px;
        background: #e0e0e0;
        z-index: 0;
        bottom: 0px;
        .bottom-line {
          position: absolute;
          bottom: 0px;
          left: 0;
          height: 2px;
          background: #007bff;
          transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
          z-index: 1;
        }
      }
      &.dark-gold-tab {
        .tab {
          background: transparent;
          color: black;
          &:hover, &.selected {
            color: #f7dd19;
          }
        }
        .bottom-line-container {
          .bottom-line {
            background: #f7dd19;
          }
        }
      }
    }
  }
}
</style>

Usage Notes ​

Tab Items Structure ​

The tabItems prop expects an array of objects with this structure:

js
[
  {
    value: 'tab1', // Unique identifier for the tab
    name: 'Tab 1', // Display name or i18n key
    icon: '<svg>...</svg>', // Optional: SVG icon as string
    prepend: '<svg>...</svg>', // Optional: Icon to prepend to the tab
    append: '<svg>...</svg>' // Optional: Icon to append to the tab
  },
  // More tabs...
]

Internationalization ​

When isLabeli18String is set to true (default), the component will attempt to translate tab names using the Vue i18n $t function. Make sure your i18n setup is properly configured.

Multiple Selection ​

When multiple is set to true, the component allows multiple tabs to be selected simultaneously. In this mode, the selected prop should be an array of tab values.

Bottom Line Style ​

When bottomLineStyle is set to true, the component will display an animated bottom line indicator that moves to the selected tab. This style is commonly used for navigation tabs.

Single Packed Style ​

When singlePacked is set to true, the tabs will be displayed in a single container with shared borders between tabs. This style is useful for option groups or segmented controls.

Custom Tab Content ​

Use the available slots to customize the appearance of each tab:

vue
<OTabs :tabItems="tabs" :selected="selectedTab">
  <template #prepend="{ item }">
    <div v-if="item.prepend" v-html="item.prepend"></div>
  </template>
  <template #tab-icon="{ item }">
    <div class="custom-tab">
      <img :src="item.icon" v-if="item.icon" />
      <span>{{ item.name }}</span>
    </div>
  </template>
  <template #append="{ item }">
    <div v-if="item.append" v-html="item.append"></div>
  </template>
</OTabs>