
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
vertical-tabsuiSettings-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/vertical-tabsInstalls to:app/components/ui/vertical-tabs/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
<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
data-uipkge=""
v-bind="{ 'data-slot': 'vertical-tabs', orientation: 'vertical', ...forwarded }"
:class="cn('flex w-full gap-6', props.class)"
>
<slot />
</TabsRoot>
</template>
<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="bg-muted pointer-events-none absolute top-0 left-0 z-0 rounded-md 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>
<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>
<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>
<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>
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