UIPackage
Menu

Framework

Change language

Boilerplate repo

Tabs

tabs ui
Boilerplate repo

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
Named registry: npx shadcn-vue@latest add @uipkge/tabs Installs to: app/components/ui/tabs/

Examples

Loading interactive previews…

Props

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

Files installed (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.9 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 motion-safe:data-[state=active]:animate-in motion-safe:data-[state=active]:fade-in-0 motion-safe:data-[state=active]:duration-200 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 6.2 kB
    <script setup lang="ts">
    import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } 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 = withDefaults(
      defineProps<{
        class?: HTMLAttributes['class']
        variant?: 'segmented' | 'pill' | 'underline'
        orientation?: 'horizontal' | 'vertical'
        asChild?: boolean
        as?: string | object
        loop?: boolean
        /** Enable sliding active indicator (default true). When false, active surface paints on the trigger. */
        animated?: boolean
      }>(),
      {
        animated: true,
      },
    )
    
    const delegatedProps = reactiveOmit(props, 'class', 'variant', 'orientation', 'animated')
    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
    })
    
    const variant = computed(() => props.variant ?? 'segmented')
    
    const listEl = ref<HTMLElement | null>(null)
    const indicatorStyle = ref<Record<string, string>>({
      opacity: '0',
    })
    let ro: ResizeObserver | null = null
    let mo: MutationObserver | null = null
    let firstPosition = true
    
    function resolveListEl(node: unknown): HTMLElement | null {
      if (!node) return null
      if (node instanceof HTMLElement) return node
      // reka-ui may expose a component instance with $el
      const el = (node as { $el?: unknown }).$el
      return el instanceof HTMLElement ? el : null
    }
    
    function setListRef(node: unknown) {
      listEl.value = resolveListEl(node)
    }
    
    function motionSafeTransition() {
      if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        return 'none'
      }
      return firstPosition
        ? 'none'
        : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)'
    }
    
    function updateIndicator() {
      if (!props.animated) return
      const root = listEl.value
      if (!root) return
      const active = root.querySelector<HTMLElement>('[data-slot="tabs-trigger"][data-state="active"]')
      if (!active) {
        indicatorStyle.value = { opacity: '0' }
        return
      }
    
      const listRect = root.getBoundingClientRect()
      const activeRect = active.getBoundingClientRect()
      const left = activeRect.left - listRect.left + root.scrollLeft
      const top = activeRect.top - listRect.top + root.scrollTop
      const transition = motionSafeTransition()
    
      if (variant.value === 'underline') {
        const thickness = 2
        if (effectiveOrientation.value === 'vertical') {
          indicatorStyle.value = {
            width: `${thickness}px`,
            height: `${activeRect.height}px`,
            transform: `translate3d(${listRect.width - thickness}px, ${top}px, 0)`,
            opacity: '1',
            transition,
          }
        } else {
          indicatorStyle.value = {
            width: `${activeRect.width}px`,
            height: `${thickness}px`,
            transform: `translate3d(${left}px, ${listRect.height - thickness}px, 0)`,
            opacity: '1',
            transition,
          }
        }
      } else {
        indicatorStyle.value = {
          width: `${activeRect.width}px`,
          height: `${activeRect.height}px`,
          transform: `translate3d(${left}px, ${top}px, 0)`,
          opacity: '1',
          transition,
        }
      }
      firstPosition = false
    }
    
    function unbindObservers() {
      ro?.disconnect()
      mo?.disconnect()
      ro = null
      mo = null
    }
    
    function bindObservers() {
      const root = listEl.value
      if (!root || !props.animated) return
    
      unbindObservers()
    
      ro = new ResizeObserver(() => updateIndicator())
      ro.observe(root)
      root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro!.observe(el))
    
      mo = new MutationObserver((mutations) => {
        // Re-observe new triggers without treating parent re-renders as first paint.
        for (const m of mutations) {
          if (m.type === 'childList') {
            root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro?.observe(el))
          }
        }
        nextTick(updateIndicator)
      })
      mo.observe(root, {
        attributes: true,
        attributeFilter: ['data-state'],
        subtree: true,
        childList: true,
      })
    
      updateIndicator()
    }
    
    onMounted(() => {
      nextTick(() => {
        // reka-ui component ref may resolve a tick later
        if (!listEl.value) {
          requestAnimationFrame(() => bindObservers())
        } else {
          bindObservers()
        }
      })
    })
    
    onBeforeUnmount(() => {
      unbindObservers()
    })
    
    watch(
      () => [variant.value, effectiveOrientation.value] as const,
      () => {
        firstPosition = true
        nextTick(updateIndicator)
      },
    )
    
    watch(
      () => props.animated,
      (on) => {
        firstPosition = true
        if (on) nextTick(() => bindObservers())
        else {
          unbindObservers()
          indicatorStyle.value = { opacity: '0' }
        }
      },
    )
    
    const indicatorClass = computed(() => {
      if (variant.value === 'pill') {
        return 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform'
      }
      if (variant.value === 'underline') {
        return 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform'
      }
      // segmented
      return 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform'
    })
    </script>
    
    <template>
      <TabsList
        :ref="setListRef"
        data-uipkge
        data-slot="tabs-list"
        :data-animated="animated ? 'true' : 'false'"
        v-bind="forwarded"
        :class="
          cn(
            'group/list relative',
            tabsListVariants({ variant, orientation: effectiveOrientation }),
            props.class,
          )
        "
      >
        <span
          v-if="animated"
          data-slot="tabs-indicator"
          aria-hidden="true"
          :class="indicatorClass"
          :style="indicatorStyle"
        />
        <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 3.5 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(
      // z-10 keeps label above the sliding indicator; active surface paints on the indicator
      // when the parent list has data-animated="true". Static active chrome restores when
      // data-animated="false" (see group-data variants below).
      'relative z-10 inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background transition-colors duration-200 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
      {
        variants: {
          variant: {
            // Active bg/shadow live on TabsList indicator so it can slide between triggers.
            segmented:
              'rounded-sm data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:bg-background group-data-[animated=false]/list:data-[state=active]:shadow-xs',
            pill: 'rounded-full border border-border bg-transparent data-[state=active]:border-transparent data-[state=active]:text-primary-foreground group-data-[animated=false]/list:data-[state=active]:bg-primary group-data-[animated=false]/list:data-[state=active]:border-primary',
            // Underline bar slides on TabsList indicator; keep transparent border for layout stability.
            underline:
              'rounded-none border-b-2 border-transparent -mb-px data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:border-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 group-data-[animated=false]/list:data-[state=active]:border-foreground',
          },
        ],
        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