Vertical Tabs
vertical-tabs ui Settings-page navigation pattern — labels stack on the left, content panel on the right. Same API as Tabs but with a vertical orientation. Use for dense, multi-section settings UIs.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/vertical-tabs.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/vertical-tabs.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/vertical-tabs.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/vertical-tabs.json Named registry:
npx shadcn-vue@latest add @uipkge/vertical-tabs Installs to: app/components/ui/vertical-tabs/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
npm dependencies
Files installed (6)
-
app/components/ui/vertical-tabs/VerticalTabs.vue 0.7 kB
<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 { cn } from '@/lib/utils' const props = defineProps< TabsRootProps & { class?: HTMLAttributes['class'] } >() const emits = defineEmits<TabsRootEmits>() const delegated = reactiveOmit(props, 'class', 'orientation') const forwarded = useForwardPropsEmits(delegated, emits) </script> <template> <TabsRoot v-bind="{ 'data-slot': 'vertical-tabs', orientation: 'vertical', ...forwarded }" :class="cn('flex w-full gap-6', props.class)" > <slot /> </TabsRoot> </template> -
app/components/ui/vertical-tabs/VerticalTabsList.vue 4.6 kB
<script setup lang="ts"> import { 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' // Inlined unions: SFC compiler can't extract runtime props from reka-ui types cleanly. const props = withDefaults( defineProps<{ class?: HTMLAttributes['class'] asChild?: boolean as?: string | object loop?: boolean /** Enable sliding active indicator (default true). When false, active chrome paints on the trigger. */ animated?: boolean }>(), { animated: true, }, ) const delegated = reactiveOmit(props, 'class', 'animated') const forwarded = useForwardProps(delegated) 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="vertical-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() // Full active surface slides (muted pill); primary rail is nested absolute so it stays inset-y-1. 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="vertical-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="vertical-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( () => props.animated, (on) => { firstPosition = true if (on) nextTick(() => bindObservers()) else { unbindObservers() indicatorStyle.value = { opacity: '0' } } }, ) </script> <template> <TabsList :ref="setListRef" data-uipkge data-slot="vertical-tabs-list" :data-animated="animated ? 'true' : 'false'" v-bind="forwarded" :class="cn('group/list border-border relative flex w-56 shrink-0 flex-col gap-0.5 border-r pr-3', props.class)" > <span v-if="animated" data-slot="vertical-tabs-indicator" aria-hidden="true" class="pointer-events-none absolute top-0 left-0 z-0 rounded-md bg-muted will-change-transform" :style="indicatorStyle" > <!-- Primary rail stays inset relative to the sliding surface (matches prior trigger chrome). --> <span class="bg-primary absolute inset-y-1 left-0 w-0.5 rounded-full" /> </span> <slot /> </TabsList> </template> -
app/components/ui/vertical-tabs/VerticalTabsSection.vue 0.5 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ label: string class?: HTMLAttributes['class'] }>() </script> <template> <div data-uipkge data-slot="vertical-tabs-section" :class=" cn( 'text-muted-foreground mt-3 mb-1 px-2 text-xs font-medium tracking-wider uppercase first:mt-0', props.class, ) " > {{ label }} </div> </template> -
app/components/ui/vertical-tabs/VerticalTabsTrigger.vue 1.8 kB
<script setup lang="ts"> import { computed } from 'vue' import type { HTMLAttributes } from 'vue' import { TabsTrigger } from 'reka-ui' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] value: string disabled?: boolean } const props = withDefaults(defineProps<Props>(), { disabled: false, }) const delegated = computed(() => { const { class: _, ...rest } = props return rest }) </script> <template> <TabsTrigger v-slot="slotProps" data-uipkge data-slot="vertical-tabs-trigger" v-bind="delegated" :class=" cn( // z-10 keeps label above the sliding indicator; active surface paints on the list // indicator when the parent list has data-animated=true. Static active chrome // restores when data-animated=false (group-data variants below). 'group/trigger text-muted-foreground relative z-10 flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition-[color,background-color] duration-150', 'hover:bg-muted/60 hover:text-foreground', 'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none', 'disabled:pointer-events-none disabled:opacity-50', 'data-[state=active]:text-foreground', 'group-data-[animated=false]/list:data-[state=active]:bg-muted', // Static primary rail (only when list animation is off). `before:bg-primary before:pointer-events-none before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:opacity-0 before:content-['']`, 'group-data-[animated=false]/list:data-[state=active]:before:opacity-100', '[&>svg]:size-4 [&>svg]:shrink-0', props.class, ) " :disabled="props.disabled" > <slot v-bind="slotProps" /> </TabsTrigger> </template> -
app/components/ui/vertical-tabs/VerticalTabsContent.vue 0.8 kB
<script setup lang="ts"> import { computed } from 'vue' import type { HTMLAttributes } from 'vue' import { TabsContent } from 'reka-ui' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] value: string forceMount?: boolean } const props = defineProps<Props>() const delegated = computed(() => { const { class: _, ...rest } = props return rest }) </script> <template> <TabsContent v-slot="slotProps" data-uipkge data-slot="vertical-tabs-content" v-bind="delegated" :class=" cn( 'ring-offset-background focus-visible:ring-ring/50 flex-1 focus-visible:ring-2 focus-visible:outline-none', props.class, ) " :force-mount="props.forceMount" > <slot v-bind="slotProps" /> </TabsContent> </template> -
app/components/ui/vertical-tabs/index.ts 0.3 kB
export { default as VerticalTabs } from './VerticalTabs.vue' export { default as VerticalTabsList } from './VerticalTabsList.vue' export { default as VerticalTabsSection } from './VerticalTabsSection.vue' export { default as VerticalTabsTrigger } from './VerticalTabsTrigger.vue' export { default as VerticalTabsContent } from './VerticalTabsContent.vue'
Raw manifest: https://uipkge.dev/r/vue/vertical-tabs.json