Tabs
tabs ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tabs.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tabs.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tabs.json $ bunx 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…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | segmented | optional |
class | HTMLAttributes['class'] | — | optional |
orientation | 'horizontal''vertical' | — | optional |
npm dependencies
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