
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
tabsuiHorizontal 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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/tabsInstalls to:app/components/ui/tabs/| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | segmented | optional |
class | HTMLAttributes['class'] | — | optional |
orientation | 'horizontal''vertical' | — | optional |
<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>
<script setup lang="ts">
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>()
</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]:blur-in-2 motion-safe:data-[state=active]:slide-in-from-bottom-1 motion-safe:data-[state=active]:ease-emphasized flex-1 focus-visible:ring-2 focus-visible:ring-[3px] focus-visible:outline-none motion-safe:data-[state=active]:duration-200',
props.class,
)
"
data-uipkge
data-slot="tabs-content"
>
<slot v-bind="slotProps" />
</TabsContent>
</template>
<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>
<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>
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(
// Scrollable when triggers overflow. Centering uses trigger auto margins
// instead of justify-center so an overflowing list stays scrollable from its
// start edge (justify-center + overflow makes leading items unreachable).
// The scrollbar is hidden; scrolling still works via wheel/touch/keyboard.
'inline-flex h-9 max-w-full items-center overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[data-slot=tabs-trigger]:first-child]:ml-auto [&>[data-slot=tabs-trigger]:last-child]:mr-auto',
{
variants: {
variant: {
// Solid muted track with rounded inset triggers — the default look.
segmented: 'gap-1 rounded-lg bg-muted p-1',
// Transparent track with rounded-full pill triggers.
pill: 'h-auto gap-2 bg-transparent p-0',
// Bottom-border bar (horizontal) or right-border bar (vertical), with
// an underline on the active trigger.
underline: 'h-auto gap-0 bg-transparent p-0',
},
orientation: {
horizontal: 'flex-row',
vertical: 'h-auto flex-col items-stretch',
},
},
compoundVariants: [
{
variant: 'underline',
orientation: 'horizontal',
// Neutralize the base auto-margin centering; this variant aligns start.
class:
'w-full justify-start border-b border-border [&>[data-slot=tabs-trigger]:first-child]:ml-0 [&>[data-slot=tabs-trigger]:last-child]:mr-0',
},
{
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 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background [&_svg]:shrink-0 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-md 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-7 px-3 text-sm',
sm: 'h-6 px-2.5 text-xs',
lg: 'h-8 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>
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