UIPackage

Tabs

Vue navigation
Edit on GitHub

Horizontal tab navigation with content panels — pick one panel at a time. Underline or pills variants. Built on reka-ui with full keyboard navigation.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tabs.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/tabs

Examples

Props

Name Type / Values Default Required
variant segmented optional
class HTMLAttributes['class'] optional
orientation
'horizontal''vertical'
optional

Dependencies

Files (6)

  • app/components/ui/tabs/Tabs.vue 1.2 kB
    <script setup lang="ts">
    import { computed, provide } from 'vue'
    import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<
      TabsRootProps & {
        class?: HTMLAttributes['class']
        orientation?: 'horizontal' | 'vertical'
      }
    >()
    const emits = defineEmits<TabsRootEmits>()
    
    const delegatedProps = reactiveOmit(props, 'class', 'orientation')
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    // Make orientation reachable from descendants without each consumer having to
    // pass it manually. TabsList / TabsTrigger inject this to apply variant CSS.
    provide(
      Symbol.for('tabsOrientation'),
      computed(() => props.orientation ?? 'horizontal'),
    )
    </script>
    
    <template>
      <TabsRoot
        data-uipkge
        data-slot="tabs"
        :data-orientation="orientation ?? 'horizontal'"
        :orientation="orientation ?? 'horizontal'"
        v-bind="forwarded"
        :class="cn('flex w-full', orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2', props.class)"
      >
        <slot />
      </TabsRoot>
    </template>
  • app/components/ui/tabs/TabsContent.vue 0.7 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { TabsContent, type TabsContentProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    interface Props {
      class?: HTMLAttributes['class']
      value: string
      forceMount?: boolean
    }
    
    const props = defineProps<Props>()
    </script>
    
    <template>
      <TabsContent
        v-slot="slotProps"
        :value="props.value"
        :force-mount="props.forceMount"
        :class="
          cn(
            'ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 flex-1 focus-visible:ring-2 focus-visible:ring-[3px] focus-visible:outline-none',
            props.class,
          )
        "
        data-uipkge
        data-slot="tabs-content"
      >
        <slot v-bind="slotProps" />
      </TabsContent>
    </template>
  • app/components/ui/tabs/TabsList.vue 1.4 kB
    <script setup lang="ts">
    import { computed, inject } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { TabsList, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { tabsListVariants } from './tabs.variants'
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // indexed-access types or reka-ui's TabsListProps.
    const props = defineProps<{
      class?: HTMLAttributes['class']
      variant?: 'segmented' | 'pill' | 'underline'
      orientation?: 'horizontal' | 'vertical'
      asChild?: boolean
      as?: string | object
      loop?: boolean
    }>()
    
    const delegatedProps = reactiveOmit(props, 'class', 'variant', 'orientation')
    const forwarded = useForwardProps(delegatedProps)
    
    // Inherit orientation from <Tabs> when not set explicitly. Provided as a
    // computed ref by Tabs.vue so updates propagate.
    const tabsOrientation = inject<{ value: 'horizontal' | 'vertical' } | 'horizontal' | 'vertical'>(
      Symbol.for('tabsOrientation'),
      'horizontal',
    )
    const effectiveOrientation = computed(() => {
      if (props.orientation) return props.orientation
      return typeof tabsOrientation === 'string' ? tabsOrientation : tabsOrientation.value
    })
    </script>
    
    <template>
      <TabsList
        data-uipkge
        data-slot="tabs-list"
        v-bind="forwarded"
        :class="cn(tabsListVariants({ variant, orientation: effectiveOrientation }), props.class)"
      >
        <slot />
      </TabsList>
    </template>
  • app/components/ui/tabs/TabsTrigger.vue 1.4 kB
    <script setup lang="ts">
    import { computed, inject } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { TabsTrigger } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { tabsTriggerVariants } from './tabs.variants'
    
    // Inlined unions: SFC compiler can't extract runtime props from
    // indexed-access types like TabsTriggerVariants['size'].
    const props = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        size?: 'default' | 'sm' | 'lg'
        variant?: 'segmented' | 'pill' | 'underline'
        orientation?: 'horizontal' | 'vertical'
        value: string
        disabled?: boolean
      }>(),
      {
        disabled: false,
      },
    )
    
    const tabsOrientation = inject<{ value: 'horizontal' | 'vertical' } | 'horizontal' | 'vertical'>(
      Symbol.for('tabsOrientation'),
      'horizontal',
    )
    const effectiveOrientation = computed(() => {
      if (props.orientation) return props.orientation
      return typeof tabsOrientation === 'string' ? tabsOrientation : tabsOrientation.value
    })
    </script>
    
    <template>
      <TabsTrigger
        v-slot="slotProps"
        :value="props.value"
        :disabled="props.disabled"
        data-uipkge
        data-slot="tabs-trigger"
        :class="
          cn(
            tabsTriggerVariants({
              size: props.size,
              variant: props.variant,
              orientation: effectiveOrientation,
            }),
            props.class,
          )
        "
      >
        <slot v-bind="slotProps" />
      </TabsTrigger>
    </template>
  • app/components/ui/tabs/tabs.variants.ts 2.9 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    /**
     * Variant definitions live in their own file (rather than the package
     * `index.ts`) so consuming Vue SFCs can import without creating a circular
     * dependency through the index. See card.variants.ts for the canonical
     * example + the SSR symptom that motivated the split.
     */
    
    export const tabsListVariants = cva('inline-flex items-stretch text-muted-foreground', {
      variants: {
        variant: {
          // Solid muted track with rounded inset triggers — the default look.
          segmented: 'gap-1 rounded-md bg-muted p-1',
          // Transparent track with rounded-full pill triggers.
          pill: 'gap-2 bg-transparent p-0',
          // Bottom-border bar (horizontal) or right-border bar (vertical), with
          // an underline on the active trigger.
          underline: 'gap-0 bg-transparent p-0',
        },
        orientation: {
          horizontal: 'flex-row',
          vertical: 'h-auto flex-col items-stretch',
        },
      },
      compoundVariants: [
        {
          variant: 'underline',
          orientation: 'horizontal',
          class: 'w-full justify-start border-b border-border',
        },
        {
          variant: 'underline',
          orientation: 'vertical',
          class: 'border-r border-border',
        },
      ],
      defaultVariants: {
        variant: 'segmented',
        orientation: 'horizontal',
      },
    })
    
    export const tabsTriggerVariants = cva(
      'inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background transition-[color,background-color,box-shadow,border-color] duration-150 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
      {
        variants: {
          variant: {
            segmented:
              'rounded-sm data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',
            pill: 'rounded-full border border-border bg-transparent data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:border-primary',
            underline:
              'rounded-none border-b-2 border-transparent -mb-px data-[state=active]:border-foreground data-[state=active]:text-foreground',
          },
          size: {
            default: 'h-9 px-3 text-sm',
            sm: 'h-8 px-2.5 text-xs',
            lg: 'h-10 px-4 text-sm',
          },
          orientation: {
            horizontal: '',
            vertical: 'w-full justify-start',
          },
        },
        compoundVariants: [
          {
            variant: 'underline',
            orientation: 'vertical',
            class: 'border-b-0 border-r-2 -mr-px',
          },
        ],
        defaultVariants: {
          variant: 'segmented',
          size: 'default',
          orientation: 'horizontal',
        },
      },
    )
    
    export type TabsListVariants = VariantProps<typeof tabsListVariants>
    
    export type TabsTriggerVariants = VariantProps<typeof tabsTriggerVariants>
  • app/components/ui/tabs/index.ts 0.5 kB
    export { default as Tabs } from './Tabs.vue'
    export { default as TabsContent } from './TabsContent.vue'
    export { default as TabsList } from './TabsList.vue'
    export { default as TabsTrigger } from './TabsTrigger.vue'
    
    // Re-export variant API from the sibling file (kept separate to avoid the
    // Component.vue <-> index.ts circular import that broke dev SSR for Card).
    export { tabsListVariants, tabsTriggerVariants, type TabsListVariants, type TabsTriggerVariants } from './tabs.variants'

Raw manifest: https://uipkge.dev/r/vue/tabs.json