---
name: tabs
title: Tabs
url: https://ui.frontic.com/components/ui/tabs.md
tier: ui
install: npx @frontic/ui add tabs
files:
  - components/ui/tabs/Tabs.vue
  - components/ui/tabs/TabsContent.vue
  - components/ui/tabs/TabsList.vue
  - components/ui/tabs/TabsTrigger.vue
  - components/ui/tabs/context.ts
  - components/ui/tabs/index.ts
dependencies:
  - "@vueuse/core"
  - class-variance-authority
  - reka-ui
---

# Tabs

Switches between panels of related content, such as a product's description and details; a filled or underlined list, in a row or a column, mirrored in a right-to-left page.

## Install

```sh
npx @frontic/ui add tabs
```

Used by: [Delivery Slot](https://ui.frontic.com/components/sections/delivery-slot.md), [Navigation Shop](https://ui.frontic.com/components/sections/navigation-shop.md)

npm packages: `@vueuse/core`, `class-variance-authority`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
</script>

<template>
  <div class="flex max-w-md flex-col gap-8">
    <Tabs default-value="description">
      <TabsList>
        <TabsTrigger value="description">Description</TabsTrigger>
        <TabsTrigger value="details">Details</TabsTrigger>
        <TabsTrigger value="reviews" disabled>Reviews</TabsTrigger>
      </TabsList>
      <TabsContent value="description">A light hybrid jacket with synthetic fill at the core and stretch panels where you move.</TabsContent>
      <TabsContent value="details">Water-repellent shell, packs into its own chest pocket.</TabsContent>
    </Tabs>
    <Tabs default-value="orders" orientation="vertical">
      <TabsList variant="line" aria-label="Account">
        <TabsTrigger value="orders">Orders</TabsTrigger>
        <TabsTrigger value="addresses">Addresses</TabsTrigger>
      </TabsList>
      <TabsContent value="orders">Track, return or reorder what you bought.</TabsContent>
      <TabsContent value="addresses">Where your orders ship and your invoices go.</TabsContent>
    </Tabs>
  </div>
</template>
```

## Anatomy

```vue
<Tabs>
  <TabsList>
    <TabsTrigger />
  </TabsList>
  <TabsContent />
</Tabs>
```

## API reference

### Tabs

Groups all parts of the tabs, which switch between panels of content.

`data-slot="tabs"` · `Tabs.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `StringOrNumber`: `string \| number` |  | The value of the tab that should be active when initially rendered. Use when you do not need to control the state of the tabs |
| `orientation` | `DataOrientation`: `'vertical' \| 'horizontal'` | `'horizontal'` | The orientation the tabs are laid out. Mainly so arrow navigation is done accordingly (left & right vs. up & down) |
| `dir` | `Direction`: `'ltr' \| 'rtl'` |  | The reading direction of the combobox when applicable.<br><br>If omitted, inherits globally from `ConfigProvider` or assumes LTR (left-to-right) reading mode. |
| `activationMode` | `'automatic' \| 'manual'` | `'automatic'` | Whether a tab is activated automatically (on focus) or manually (on click). |
| `modelValue` | `StringOrNumber`: `string \| number` |  | The controlled value of the tab to activate. Can be bind as `v-model`. |
| `unmountOnHide` | `boolean` | `true` | When `true`, the element will be unmounted on closed state. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `payload: StringOrNumber` | Event handler called when the value changes |

| Slot | Slot props | Description |
| --- | --- | --- |
| `default` | `{ modelValue: string \| number \| undefined; }` | Holds the tab list and panels; gets the value of the active tab. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-horizontal` |  | Present when the part is laid out horizontally. |
| `data-orientation` | `'vertical' \| 'horizontal'` | The direction the part is laid out in. |
| `data-vertical` |  | Present when the part is laid out vertically. |

### TabsContent

Holds what its tab shows while that tab is selected.

`data-slot="tabs-content"` · `TabsContent.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `StringOrNumber`: `string \| number` |  | A unique value that associates the content with a trigger. |
| `forceMount` | `boolean` | `false` | Used to force mounting when more control is needed. Useful when controlling animation with Vue animation libraries. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |
| `data-state` | `'active' \| 'inactive'` | The state the part is in, one value per state. |

### TabsList

Groups the triggers that switch between the tabs.

`data-slot="tabs-list"` · `TabsList.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `loop` | `boolean` | `true` | When `true`, keyboard navigation will loop from last tab to first, and vice versa. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` |  | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `variant` | `TabsListVariant`: `'default' \| 'line'` | `'default'` | The visual style: tabs on a muted bar, or a line under the active tab. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-variant` | `'default' \| 'line'` | Carries the `variant` prop. |

### TabsTrigger

Lets the user switch to its tab. Renders a `<button>`.

`data-slot="tabs-trigger"` · `TabsTrigger.vue`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `StringOrNumber`: `string \| number` |  | A unique value that associates the trigger with a content. |
| `disabled` | `boolean` | `false` | When `true`, prevents the user from interacting with the tab. |
| `asChild` | `boolean` | `false` | Change the default rendered element for the one passed as a child, merging their props and behavior.<br><br>Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details. |
| `as` | `AsTag \| Component`: `ComponentOptions<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions> \| (props: any, ctx: Omit<{ attrs: Attrs; slots: Readonly<InternalSlots>; emit: (event: string, ...args: any[]) => void; expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed \| undefined) => void; }, 'expose'>): any \| ComponentPublicInstanceConstructor<any, any, any, any, ComputedOptions, MethodOptions> \| 'a' \| 'button' \| 'div' \| 'form' \| 'h2' \| 'h3' \| 'img' \| 'input' \| 'label' \| 'li' \| 'nav' \| 'ol' \| 'p' \| 'span' \| 'svg' \| 'ul' \| 'template' \| {} & string` | `'button'` | The element or component this component should render as. Can be overwritten by `asChild`. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-disabled` |  | Present while the part is disabled. |
| `data-orientation` | `'horizontal' \| 'vertical'` | The direction the part is laid out in. |
| `data-state` | `'active' \| 'inactive'` | The state the part is in, one value per state. |

## Exports

```ts
import { type TabsListVariant } from '@/components/ui/tabs'
```

- `TabsListVariant`: `'default' \| 'line'`

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`tabs`](https://ui.frontic.com/DESIGN.md#tabs) |
| `list` | [`tabs-list`](https://ui.frontic.com/DESIGN.md#tabs-list) |
| `trigger` | [`tabs-trigger`](https://ui.frontic.com/DESIGN.md#tabs-trigger) |
| `trigger-indicator` | [`tabs-trigger-indicator`](https://ui.frontic.com/DESIGN.md#tabs-trigger-indicator) |
| `content` | [`tabs-content`](https://ui.frontic.com/DESIGN.md#tabs-content) |
| `disabled` | [`disabled`](https://ui.frontic.com/DESIGN.md#disabled) |

States with a look of their own: hover, focus and disabled.

## Source

`components/ui/tabs/Tabs.vue`

```vue
<script lang="ts">
/** Groups all parts of the tabs, which switch between panels of content. */
export default {}
</script>

<script setup lang="ts">
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import { computed } from 'vue'

const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<TabsRootEmits>()

const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const vertical = computed(() => props.orientation === 'vertical')

defineSlots<{
  /** Holds the tab list and panels; gets the value of the active tab. */
  default?: (props: { modelValue: string | number | undefined }) => unknown
}>()
</script>

<template>
  <TabsRoot
    v-slot="slotProps"
    data-slot="tabs"
    :data-orientation="vertical ? 'vertical' : 'horizontal'"
    :data-horizontal="vertical ? undefined : ''"
    :data-vertical="vertical ? '' : undefined"
    v-bind="forwarded"
    :class="['fui-tabs group/tabs data-horizontal:flex-col', props.class]"
  >
    <slot v-bind="slotProps" />
  </TabsRoot>
</template>
```

`components/ui/tabs/TabsContent.vue`

```vue
<script lang="ts">
/** Holds what its tab shows while that tab is selected. */
export default {}
</script>

<script setup lang="ts">
import type { TabsContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TabsContent, useForwardProps } from 'reka-ui'

const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()

const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>

<template>
  <TabsContent data-slot="tabs-content" :class="['fui-tabs-content outline-none', props.class]" v-bind="forwarded">
    <slot />
  </TabsContent>
</template>
```

`components/ui/tabs/TabsList.vue`

```vue
<script lang="ts">
/** Groups the triggers that switch between the tabs. */
export default {}
</script>

<script setup lang="ts">
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { TabsListVariant } from './context'
import { reactiveOmit } from '@vueuse/core'
import { cva } from 'class-variance-authority'
import { TabsList, useForwardProps } from 'reka-ui'
import { computed, provide } from 'vue'
import { TABS_LIST_VARIANT } from './context'

const tabsList = cva('fui-tabs-list group/tabs-list group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col', {
  variants: {
    variant: {
      default: 'fui-tabs-list-default',
      line: 'fui-tabs-list-line',
    },
  },
  defaultVariants: { variant: 'default' },
})

const props = withDefaults(
  defineProps<
    TabsListProps & {
      class?: HTMLAttributes['class']
      /** The visual style: tabs on a muted bar, or a line under the active tab. */
      variant?: TabsListVariant
    }
  >(),
  {
    variant: 'default',
  },
)

const delegatedProps = reactiveOmit(props, 'class', 'variant')
const forwarded = useForwardProps(delegatedProps)

provide(
  TABS_LIST_VARIANT,
  computed(() => props.variant),
)
</script>

<template>
  <TabsList data-slot="tabs-list" :data-variant="props.variant" v-bind="forwarded" :class="[tabsList({ variant: props.variant }), props.class]">
    <slot />
  </TabsList>
</template>
```

`components/ui/tabs/TabsTrigger.vue`

```vue
<script lang="ts">
/** Lets the user switch to its tab. */
export default {}
</script>

<script setup lang="ts">
import type { TabsTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cva } from 'class-variance-authority'
import { TabsTrigger, useForwardProps } from 'reka-ui'
import { computed, inject } from 'vue'
import { TABS_LIST_VARIANT } from './context'

const tabsTrigger = cva(
  'fui-tabs-trigger group/tabs-trigger relative whitespace-nowrap transition-all outline-none group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start disabled:pointer-events-none disabled:fui-disabled *:data-icon:pointer-events-none *:data-icon:shrink-0',
  {
    variants: {
      variant: {
        default: 'fui-tabs-trigger-default',
        line: 'fui-tabs-trigger-line',
      },
    },
    defaultVariants: { variant: 'default' },
  },
)

const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()

const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const injected = inject(TABS_LIST_VARIANT, undefined)
const variant = computed(() => injected?.value ?? 'default')
</script>

<template>
  <TabsTrigger data-slot="tabs-trigger" :class="[tabsTrigger({ variant }), props.class]" v-bind="forwardedProps">
    <slot />
    <span
      v-if="variant === 'line'"
      aria-hidden="true"
      class="absolute opacity-0 transition-opacity group-aria-selected/tabs-trigger:opacity-100 group-data-horizontal/tabs:inset-x-0 group-data-horizontal/tabs:-bottom-1.25 group-data-horizontal/tabs:h-0.5 group-data-vertical/tabs:inset-y-0 group-data-vertical/tabs:-inset-e-1 group-data-vertical/tabs:w-0.5"
    >
      <span data-slot="tabs-trigger-indicator" class="fui-tabs-trigger-indicator block size-full" />
    </span>
  </TabsTrigger>
</template>
```

`components/ui/tabs/context.ts`

```ts
import type { ComputedRef, InjectionKey } from 'vue'

export type TabsListVariant = 'default' | 'line'

export const TABS_LIST_VARIANT: InjectionKey<ComputedRef<TabsListVariant>> = Symbol('tabs-list-variant')
```
