Skip to content

Tabs

A set of tab panels that are displayed one at a time.

Demo

Lorem ipsum dolor sit amet consectetur adipisicing elit.

This requires the following components to be installed:

This requires the following utils to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Tabs.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { DynamicSlots } from '@/ui/utils/utils'
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/ui/components/Avatar.vue'
import Icon from '@/ui/components/Icon.vue'
import theme from '@/ui/theme/tabs'
import { get } from '@/ui/utils/get'
import { reactivePick } from '@vueuse/shared'
import { TabsContent, TabsIndicator, TabsList, TabsRoot, TabsTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const tabs = tv(theme)

export interface TabsItem {
  label?: string
  icon?: string
  avatar?: AvatarProps
  slot?: string
  content?: string
  value?: string | number
  disabled?: boolean
  [key: string]: any
}

type TabsVariants = VariantProps<typeof tabs>

export interface TabsProps<T extends TabsItem = TabsItem> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> {
  as?: any
  items?: T[]
  color?: TabsVariants['color']
  variant?: TabsVariants['variant']
  size?: TabsVariants['size']
  orientation?: TabsRootProps['orientation']
  content?: boolean
  labelKey?: string
  class?: any
  ui?: Partial<typeof tabs.slots>
}

export interface TabsEmits extends TabsRootEmits<string | number> {}

type SlotProps<T extends TabsItem> = (props: { item: T, index: number }) => any

export type TabsSlots<T extends TabsItem = TabsItem> = {
  leading: SlotProps<T>
  default: SlotProps<T>
  trailing: SlotProps<T>
  content: SlotProps<T>
} & DynamicSlots<T, undefined, { index: number }>
</script>

<script setup lang="ts" generic="T extends TabsItem">
const props = withDefaults(defineProps<TabsProps<T>>(), {
  content: true,
  defaultValue: '0',
  orientation: 'horizontal',
  unmountOnHide: true,
  labelKey: 'label',
})
const emits = defineEmits<TabsEmits>()
const slots = defineSlots<TabsSlots<T>>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'activationMode', 'unmountOnHide'), emits)

const ui = computed(() => tabs({
  color: props.color,
  variant: props.variant,
  size: props.size,
  orientation: props.orientation,
}))
</script>

<template>
  <TabsRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <TabsList :class="ui.list({ class: props.ui?.list })">
      <TabsIndicator :class="ui.indicator({ class: props.ui?.indicator })" />

      <TabsTrigger v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.trigger({ class: props.ui?.trigger })">
        <slot name="leading" :item="item" :index="index">
          <Icon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
          <Avatar v-else-if="item.avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
        </slot>

        <span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: props.ui?.label })">
          <slot :item="item" :index="index">{{ get(item, props.labelKey as string) }}</slot>
        </span>

        <slot name="trailing" :item="item" :index="index" />
      </TabsTrigger>
    </TabsList>

    <template v-if="!!content">
      <TabsContent v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :class="ui.content({ class: props.ui?.content })">
        <slot :name="((item.slot || 'content') as keyof TabsSlots<T>)" :item="(item as Extract<T, { slot: string; }>)" :index="index">
          {{ item.content }}
        </slot>
      </TabsContent>
    </template>
  </TabsRoot>
</template>
Tabs.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { DynamicSlots } from '@/UI/Utils/utils'
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Avatar from '@/UI/Components/Avatar.vue'
import Icon from '@/UI/Components/Icon.vue'
import theme from '@/UI/Theme/tabs'
import { get } from '@/UI/Utils/get'
import { reactivePick } from '@vueuse/shared'
import { TabsContent, TabsIndicator, TabsList, TabsRoot, TabsTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const tabs = tv(theme)

export interface TabsItem {
  label?: string
  icon?: string
  avatar?: AvatarProps
  slot?: string
  content?: string
  value?: string | number
  disabled?: boolean
  [key: string]: any
}

type TabsVariants = VariantProps<typeof tabs>

export interface TabsProps<T extends TabsItem = TabsItem> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> {
  as?: any
  items?: T[]
  color?: TabsVariants['color']
  variant?: TabsVariants['variant']
  size?: TabsVariants['size']
  orientation?: TabsRootProps['orientation']
  content?: boolean
  labelKey?: string
  class?: any
  ui?: Partial<typeof tabs.slots>
}

export interface TabsEmits extends TabsRootEmits<string | number> {}

type SlotProps<T extends TabsItem> = (props: { item: T, index: number }) => any

export type TabsSlots<T extends TabsItem = TabsItem> = {
  leading: SlotProps<T>
  default: SlotProps<T>
  trailing: SlotProps<T>
  content: SlotProps<T>
} & DynamicSlots<T, undefined, { index: number }>
</script>

<script setup lang="ts" generic="T extends TabsItem">
const props = withDefaults(defineProps<TabsProps<T>>(), {
  content: true,
  defaultValue: '0',
  orientation: 'horizontal',
  unmountOnHide: true,
  labelKey: 'label',
})
const emits = defineEmits<TabsEmits>()
const slots = defineSlots<TabsSlots<T>>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'activationMode', 'unmountOnHide'), emits)

const ui = computed(() => tabs({
  color: props.color,
  variant: props.variant,
  size: props.size,
  orientation: props.orientation,
}))
</script>

<template>
  <TabsRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <TabsList :class="ui.list({ class: props.ui?.list })">
      <TabsIndicator :class="ui.indicator({ class: props.ui?.indicator })" />

      <TabsTrigger v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.trigger({ class: props.ui?.trigger })">
        <slot name="leading" :item="item" :index="index">
          <Icon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
          <Avatar v-else-if="item.avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
        </slot>

        <span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: props.ui?.label })">
          <slot :item="item" :index="index">{{ get(item, props.labelKey as string) }}</slot>
        </span>

        <slot name="trailing" :item="item" :index="index" />
      </TabsTrigger>
    </TabsList>

    <template v-if="!!content">
      <TabsContent v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :class="ui.content({ class: props.ui?.content })">
        <slot :name="((item.slot || 'content') as keyof TabsSlots<T>)" :item="(item as Extract<T, { slot: string; }>)" :index="index">
          {{ item.content }}
        </slot>
      </TabsContent>
    </template>
  </TabsRoot>
</template>

Theme

tabs.ts
ts
export default {
  slots: {
    root: '',
    list: '',
    indicator: '',
    trigger: '',
    content: '',
    leadingIcon: '',
    leadingAvatar: '',
    leadingAvatarSize: '',
    label: '',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    variant: {
      pill: {
        list: '',
        trigger: '',
        indicator: '',
      },
      link: {
        list: '',
        indicator: '',
      },
    },
    orientation: {
      horizontal: {
        root: '',
        list: '',
        indicator: '',
        trigger: '',
      },
      vertical: {
        list: '',
        indicator: '',
      },
    },
    size: {
      xs: {
        trigger: '',
        leadingIcon: '',
        leadingAvatarSize: '',
      },
      sm: {
        trigger: '',
        leadingIcon: '',
        leadingAvatarSize: '',
      },
      md: {
        trigger: '',
        leadingIcon: '',
        leadingAvatarSize: '',
      },
      lg: {
        trigger: '',
        leadingIcon: '',
        leadingAvatarSize: '',
      },
      xl: {
        trigger: '',
        leadingIcon: '',
        leadingAvatarSize: '',
      },
    },
  },
  compoundVariants: [],
  defaultVariants: {
    color: 'primary',
    variant: 'pill',
    size: 'md',
  } as const,
}
View Nuxt UI theme
tabs.ts
ts
export default {
  slots: {
    root: 'flex items-center gap-2',
    list: 'relative flex p-1 group',
    indicator: 'absolute transition-[translate,width] duration-200',
    trigger: 'group relative inline-flex items-center shrink-0 min-w-0 data-[state=inactive]:text-muted hover:data-[state=inactive]:not-disabled:text-default font-medium rounded-md disabled:cursor-not-allowed disabled:opacity-75 focus:outline-hidden transition-colors',
    content: 'focus:outline-none w-full',
    leadingIcon: 'shrink-0',
    leadingAvatar: 'shrink-0',
    leadingAvatarSize: '',
    label: 'truncate',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    variant: {
      pill: {
        list: 'bg-elevated rounded-lg',
        trigger: 'flex-1 w-full',
        indicator: 'rounded-md shadow-xs',
      },
      link: {
        list: 'border-default',
        indicator: 'rounded-full',
      },
    },
    orientation: {
      horizontal: {
        root: 'flex-col',
        list: 'w-full',
        indicator: 'left-0 w-(--reka-tabs-indicator-size) translate-x-(--reka-tabs-indicator-position)',
        trigger: 'justify-center',
      },
      vertical: {
        list: 'flex-col',
        indicator: 'top-0 h-(--reka-tabs-indicator-size) translate-y-(--reka-tabs-indicator-position)',
      },
    },
    size: {
      xs: {
        trigger: 'px-2 py-1 text-xs gap-1',
        leadingIcon: 'size-4',
        leadingAvatarSize: '3xs',
      },
      sm: {
        trigger: 'px-2.5 py-1.5 text-xs gap-1.5',
        leadingIcon: 'size-4',
        leadingAvatarSize: '3xs',
      },
      md: {
        trigger: 'px-3 py-1.5 text-sm gap-1.5',
        leadingIcon: 'size-5',
        leadingAvatarSize: '2xs',
      },
      lg: {
        trigger: 'px-3 py-2 text-sm gap-2',
        leadingIcon: 'size-5',
        leadingAvatarSize: '2xs',
      },
      xl: {
        trigger: 'px-3 py-2 text-base gap-2',
        leadingIcon: 'size-6',
        leadingAvatarSize: 'xs',
      },
    },
  },
  compoundVariants: [
    {
      orientation: 'horizontal',
      variant: 'pill',
      class: {
        indicator: 'inset-y-1',
      },
    } as const,
    {
      orientation: 'horizontal',
      variant: 'link',
      class: {
        list: 'border-b -mb-px',
        indicator: '-bottom-px h-px',
      },
    } as const,
    {
      orientation: 'vertical',
      variant: 'pill',
      class: {
        indicator: 'inset-x-1',
        list: 'items-center',
      },
    } as const,
    {
      orientation: 'vertical',
      variant: 'link',
      class: {
        list: 'border-s -ms-px',
        indicator: '-start-px w-px',
      },
    } as const,
    {
      color: 'primary',
      variant: 'pill',
      class: {
        indicator: 'bg-primary',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
      },
    } as const,
    {
      color: 'primary',
      variant: 'link',
      class: {
        indicator: 'bg-primary',
        trigger: 'data-[state=active]:text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
      },
    } as const,
    {
      color: 'secondary',
      variant: 'pill',
      class: {
        indicator: 'bg-secondary',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary',
      },
    } as const,
    {
      color: 'secondary',
      variant: 'link',
      class: {
        indicator: 'bg-secondary',
        trigger: 'data-[state=active]:text-secondary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-secondary',
      },
    } as const,
    {
      color: 'success',
      variant: 'pill',
      class: {
        indicator: 'bg-success',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success',
      },
    } as const,
    {
      color: 'success',
      variant: 'link',
      class: {
        indicator: 'bg-success',
        trigger: 'data-[state=active]:text-success focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-success',
      },
    } as const,
    {
      color: 'info',
      variant: 'pill',
      class: {
        indicator: 'bg-info',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
      },
    } as const,
    {
      color: 'info',
      variant: 'link',
      class: {
        indicator: 'bg-info',
        trigger: 'data-[state=active]:text-info focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-info',
      },
    } as const,
    {
      color: 'warning',
      variant: 'pill',
      class: {
        indicator: 'bg-warning',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning',
      },
    } as const,
    {
      color: 'warning',
      variant: 'link',
      class: {
        indicator: 'bg-warning',
        trigger: 'data-[state=active]:text-warning focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-warning',
      },
    } as const,
    {
      color: 'error',
      variant: 'pill',
      class: {
        indicator: 'bg-error',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-error',
      },
    } as const,
    {
      color: 'error',
      variant: 'link',
      class: {
        indicator: 'bg-error',
        trigger: 'data-[state=active]:text-error focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-error',
      },
    } as const,
    {
      color: 'neutral',
      variant: 'pill',
      class: {
        indicator: 'bg-inverted',
        trigger: 'data-[state=active]:text-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-inverted',
      },
    } as const,
    {
      color: 'neutral',
      variant: 'link',
      class: {
        indicator: 'bg-inverted',
        trigger: 'data-[state=active]:text-highlighted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted',
      },
    } as const,
  ],
  defaultVariants: {
    color: 'primary',
    variant: 'pill',
    size: 'md',
  } as const,
}

Test

To test this component, you can use the following test file:

Tabs.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Tabs from '@/ui/components/Tabs.vue'
import theme from '@/ui/theme/tabs'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('tabs', () => {
  const variants = Object.keys(theme.variants.variant) as any
  const sizes = Object.keys(theme.variants.size) as any

  const items = [{
    label: 'Tab1',
    avatar: {
      src: 'https://github.com/vue.png',
    },
    content: 'This is the content shown for Tab1',
  }, {
    label: 'Tab2',
    icon: 'i-lucide-user',
    content: 'And, this is the content for Tab2',
  }, {
    label: 'Tab3',
    icon: 'i-lucide-bell',
    content: 'Finally, this is the content for Tab3',
    slot: 'custom',
  }]

  const props = { items }

  it.each<[string, RenderOptions<typeof Tabs>]>([
    // Props
    ['with items', { props }],
    ['with labelKey', { props: { ...props, labelKey: 'icon' } }],
    ['with modelValue', { props: { ...props, modelValue: '1' } }],
    ['with defaultValue', { props: { ...props, defaultValue: '1' } }],
    ['with orientation vertical', { props: { ...props, orientation: 'vertical' as const } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant, color: 'neutral' } }]),
    ['without content', { props: { ...props, content: false } }],
    ['with unmountOnHide', { props: { ...props, unmountOnHide: false } }],
    ['with as', { props: { ...props, as: 'section' } }],
    ['with class', { props: { ...props, class: 'w-96' } }],
    ['with ui', { props: { ...props, ui: { content: 'w-full ring ring-(--ui-border)' } } }],
    // Slots
    ['with leading slot', { props, slots: { leading: () => 'Leading slot' } }],
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with trailing slot', { props, slots: { trailing: () => 'Trailing slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Tabs, options)

    expect(html()).toMatchSnapshot()
  })
})
Tabs.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Tabs from '@/UI/Components/Tabs.vue'
import theme from '@/UI/Theme/tabs'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('tabs', () => {
  const variants = Object.keys(theme.variants.variant) as any
  const sizes = Object.keys(theme.variants.size) as any

  const items = [{
    label: 'Tab1',
    avatar: {
      src: 'https://github.com/vue.png',
    },
    content: 'This is the content shown for Tab1',
  }, {
    label: 'Tab2',
    icon: 'i-lucide-user',
    content: 'And, this is the content for Tab2',
  }, {
    label: 'Tab3',
    icon: 'i-lucide-bell',
    content: 'Finally, this is the content for Tab3',
    slot: 'custom',
  }]

  const props = { items }

  it.each<[string, RenderOptions<typeof Tabs>]>([
    // Props
    ['with items', { props }],
    ['with labelKey', { props: { ...props, labelKey: 'icon' } }],
    ['with modelValue', { props: { ...props, modelValue: '1' } }],
    ['with defaultValue', { props: { ...props, defaultValue: '1' } }],
    ['with orientation vertical', { props: { ...props, orientation: 'vertical' as const } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant } }]),
    ...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant, color: 'neutral' } }]),
    ['without content', { props: { ...props, content: false } }],
    ['with unmountOnHide', { props: { ...props, unmountOnHide: false } }],
    ['with as', { props: { ...props, as: 'section' } }],
    ['with class', { props: { ...props, class: 'w-96' } }],
    ['with ui', { props: { ...props, ui: { content: 'w-full ring ring-(--ui-border)' } } }],
    // Slots
    ['with leading slot', { props, slots: { leading: () => 'Leading slot' } }],
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with trailing slot', { props, slots: { trailing: () => 'Trailing slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Tabs, options)

    expect(html()).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

c615c - feat: add custom eslint rule to disallow relative imports (#81) on 1/7/2025
7d577 - feat: add toast component (#77) on 1/3/2025
f0520 - feat: add tabs component (#25) on 12/15/2024