UIPackage
Menu

Framework

Change language

Boilerplate repo

Vertical Tabs

vertical-tabs ui
Boilerplate repo

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

$ npx 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…

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