Tabs
A set of tab panels that are displayed one at a time.
Demo
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Related Components
This requires the following components to be installed:
Related Utils
This requires the following utils to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
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>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
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
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:
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()
})
})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()
})
})