Skip to content

Tabs

INFO

This page has not yet been updated to use the defineModel macro, which was added in Vue 3.4. The techniques described here should still work, but in some cases it might be better to use defineModel instead.

Tabs Example

There are various ways to split a tabbed UI up into components. In this example we're going to use two components, an outer container called tabs and an inner container for each child called tab:

vue
<template>
  <basic-tabs>
    <basic-tab title="First">
      First tab content
    </basic-tab>
    <basic-tab title="Second">
      <template v-for="i in 20">
        Second tab content {{ i }}
        <br v-if="i !== 20">
      </template>
    </basic-tab>
    <basic-tab title="Third">
      Third tab content
    </basic-tab>
  </basic-tabs>
</template>

<script setup>
import BasicTabs from './tabs.vue'
import BasicTab from './tab.vue'
</script>
<template>
  <basic-tabs>
    <basic-tab title="First">
      First tab content
    </basic-tab>
    <basic-tab title="Second">
      <template v-for="i in 20">
        Second tab content {{ i }}
        <br v-if="i !== 20">
      </template>
    </basic-tab>
    <basic-tab title="Third">
      Third tab content
    </basic-tab>
  </basic-tabs>
</template>

<script setup>
import BasicTabs from './tabs.vue'
import BasicTab from './tab.vue'
</script>
Live Example
First tab content

SFC Playground

The title prop is used to pass the text to show on the button, with a slot for the content.

The code for tabs.vue looks long, but a lot of it is CSS:

vue
<template>
  <div class="tabs">
    <div class="header">
      <!-- TODO: key -->
      <button
        v-for="tab in tabs"
        class="tab-button"
        :class="{ active: tab === active }"
        @click="activate(tab)"
      >{{ tab.title }}</button>
    </div>
    <div class="body">
      <slot />
    </div>
  </div>
</template>

<script setup>
import { computed, provide, reactive, ref } from 'vue'

const active = ref(null)
const tabs = reactive([])

const activate = tab => {
  active.value = tab
}

provide('tabs-register', tab => {
  tabs.push(tab)

  if (!active.value) {
    activate(tab)
  }

  return {
    active: computed(() => active.value === tab),

    unregister () {
      const index = tabs.indexOf(tab)
      tabs.splice(index, 1)

      if (active.value === tab) {
        activate(tabs[0])
      }
    }
  }
})
</script>

<style scoped>
.tabs {
  border: 1px solid #ccc;
  display: flex;
  flex-direction: column;
  height: 200px;
  width: 400px;
}

.header {
  background-color: #e6f6ff;
  border-bottom: 1px solid #ccc;
  display: flex;
  padding: 2px 2px 0;
}

.tab-button {
  background: #fff;
  border: 1px solid #ccc;
  border-bottom: 0;
  border-radius: 5px 5px 0 0;
  cursor: pointer;
  margin-right: 2px;
  min-width: 70px;
  padding: 5px;
}

.tab-button:hover {
  background-color: #f7fcff;
}

.active {
  background-color: #f7fcff;
  margin-bottom: -1px;
  padding-bottom: 6px;
}

.body {
  background-color: #f7fcff;
  display: flex;
  flex: 1;
  flex-direction: column;
  overflow: auto;
}
</style>
<template>
  <div class="tabs">
    <div class="header">
      <!-- TODO: key -->
      <button
        v-for="tab in tabs"
        class="tab-button"
        :class="{ active: tab === active }"
        @click="activate(tab)"
      >{{ tab.title }}</button>
    </div>
    <div class="body">
      <slot />
    </div>
  </div>
</template>

<script setup>
import { computed, provide, reactive, ref } from 'vue'

const active = ref(null)
const tabs = reactive([])

const activate = tab => {
  active.value = tab
}

provide('tabs-register', tab => {
  tabs.push(tab)

  if (!active.value) {
    activate(tab)
  }

  return {
    active: computed(() => active.value === tab),

    unregister () {
      const index = tabs.indexOf(tab)
      tabs.splice(index, 1)

      if (active.value === tab) {
        activate(tabs[0])
      }
    }
  }
})
</script>

<style scoped>
.tabs {
  border: 1px solid #ccc;
  display: flex;
  flex-direction: column;
  height: 200px;
  width: 400px;
}

.header {
  background-color: #e6f6ff;
  border-bottom: 1px solid #ccc;
  display: flex;
  padding: 2px 2px 0;
}

.tab-button {
  background: #fff;
  border: 1px solid #ccc;
  border-bottom: 0;
  border-radius: 5px 5px 0 0;
  cursor: pointer;
  margin-right: 2px;
  min-width: 70px;
  padding: 5px;
}

.tab-button:hover {
  background-color: #f7fcff;
}

.active {
  background-color: #f7fcff;
  margin-bottom: -1px;
  padding-bottom: 6px;
}

.body {
  background-color: #f7fcff;
  display: flex;
  flex: 1;
  flex-direction: column;
  overflow: auto;
}
</style>

tab.vue doesn't need much code:

vue
<template>
  <div v-if="active" class="tab">
    <slot />
  </div>
</template>

<script setup>
import { inject, onUnmounted, reactive, toRef } from 'vue'

const props = defineProps({
  title: {
    required: true,
    type: String
  }
})

const register = inject('tabs-register')

const tab = reactive({
  title: toRef(props, 'title')
})

const { active, unregister } = register(tab)

onUnmounted(unregister)
</script>
<template>
  <div v-if="active" class="tab">
    <slot />
  </div>
</template>

<script setup>
import { inject, onUnmounted, reactive, toRef } from 'vue'

const props = defineProps({
  title: {
    required: true,
    type: String
  }
})

const register = inject('tabs-register')

const tab = reactive({
  title: toRef(props, 'title')
})

const { active, unregister } = register(tab)

onUnmounted(unregister)
</script>

Vue Patterns

See Coupled Components with provide/inject

Libraries

Various libraries include a tabs component, including: