
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 Radix UI with full keyboard navigation.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/tabs.json$npx shadcn@latest add https://uipkge.dev/r/react/tabs.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/tabs.json$bunx shadcn@latest add https://uipkge.dev/r/react/tabs.jsonnpx shadcn@latest add @uipkge-react/tabsInstalls to:components/ui/tabs/| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | segmented | optional |
orientation | 'horizontal''vertical' | horizontal | optional |
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
import { tabsListVariants, tabsTriggerVariants } from './tabs.variants'
// Make orientation reachable from descendants without each consumer having to
// pass it manually. TabsList / TabsTrigger read this to apply variant CSS.
const TabsOrientationContext = React.createContext<'horizontal' | 'vertical'>('horizontal')
export interface TabsProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> {
orientation?: 'horizontal' | 'vertical'
}
const Tabs = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Root>, TabsProps>(
({ className, orientation = 'horizontal', ...props }, ref) => (
<TabsOrientationContext.Provider value={orientation}>
<TabsPrimitive.Root
ref={ref}
data-uipkge=""
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('flex w-full', orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2', className)}
{...props}
/>
</TabsOrientationContext.Provider>
),
)
Tabs.displayName = 'Tabs'
export interface TabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
variant?: 'segmented' | 'pill' | 'underline'
orientation?: 'horizontal' | 'vertical'
/** Enable sliding active indicator (default true). When false, active surface paints on the trigger. */
animated?: boolean
}
const TabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, TabsListProps>(
({ className, variant = 'segmented', orientation, animated = true, children, ...props }, ref) => {
const inherited = React.useContext(TabsOrientationContext)
const effectiveOrientation = orientation ?? inherited
const listRef = React.useRef<HTMLDivElement | null>(null)
const firstPosition = React.useRef(true)
const [indicatorStyle, setIndicatorStyle] = React.useState<React.CSSProperties>({ opacity: 0 })
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
listRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref],
)
const motionSafeTransition = React.useCallback(() => {
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return 'none'
}
return firstPosition.current
? '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)'
}, [])
const updateIndicator = React.useCallback(() => {
if (!animated) return
const root = listRef.current
if (!root) return
const active = root.querySelector<HTMLElement>('[data-slot="tabs-trigger"][data-state="active"]')
if (!active) {
setIndicatorStyle({ 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 === 'underline') {
const thickness = 2
if (effectiveOrientation === 'vertical') {
setIndicatorStyle({
width: thickness,
height: activeRect.height,
transform: `translate3d(${listRect.width - thickness}px, ${top}px, 0)`,
opacity: 1,
transition,
})
} else {
setIndicatorStyle({
width: activeRect.width,
height: thickness,
transform: `translate3d(${left}px, ${listRect.height - thickness}px, 0)`,
opacity: 1,
transition,
})
}
} else {
setIndicatorStyle({
width: activeRect.width,
height: activeRect.height,
transform: `translate3d(${left}px, ${top}px, 0)`,
opacity: 1,
transition,
})
}
firstPosition.current = false
}, [animated, effectiveOrientation, motionSafeTransition, variant])
// Bind observers once per animated/variant/orientation — NOT on every children identity
// change (controlled tabs re-render parents constantly and would kill the slide).
React.useLayoutEffect(() => {
firstPosition.current = true
if (!animated) {
setIndicatorStyle({ opacity: 0 })
return
}
updateIndicator()
const root = listRef.current
if (!root) return
const ro = new ResizeObserver(() => updateIndicator())
ro.observe(root)
root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el))
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
root.querySelectorAll('[data-slot="tabs-trigger"]').forEach((el) => ro.observe(el))
}
}
updateIndicator()
})
mo.observe(root, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
childList: true,
})
return () => {
ro.disconnect()
mo.disconnect()
}
}, [animated, effectiveOrientation, updateIndicator, variant])
const indicatorClass =
variant === 'pill'
? 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform'
: variant === 'underline'
? 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform'
: 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform'
return (
<TabsPrimitive.List
ref={setRefs}
data-uipkge=""
data-slot="tabs-list"
data-animated={animated ? 'true' : 'false'}
className={cn(
'group/list relative',
tabsListVariants({ variant, orientation: effectiveOrientation }),
className,
)}
{...props}
>
{animated && (
<span data-slot="tabs-indicator" aria-hidden="true" className={indicatorClass} style={indicatorStyle} />
)}
{children}
</TabsPrimitive.List>
)
},
)
TabsList.displayName = 'TabsList'
export interface TabsTriggerProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> {
size?: 'default' | 'sm' | 'lg'
variant?: 'segmented' | 'pill' | 'underline'
orientation?: 'horizontal' | 'vertical'
}
const TabsTrigger = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Trigger>, TabsTriggerProps>(
({ className, size, variant, orientation, ...props }, ref) => {
const inherited = React.useContext(TabsOrientationContext)
const effectiveOrientation = orientation ?? inherited
return (
<TabsPrimitive.Trigger
ref={ref}
data-uipkge=""
data-slot="tabs-trigger"
className={cn(tabsTriggerVariants({ size, variant, orientation: effectiveOrientation }), className)}
{...props}
/>
)
},
)
TabsTrigger.displayName = 'TabsTrigger'
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
data-uipkge=""
data-slot="tabs-content"
className={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',
className,
)}
{...props}
/>
))
TabsContent.displayName = 'TabsContent'
export { Tabs, TabsList, TabsTrigger, TabsContent }
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 {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
type TabsProps,
type TabsListProps,
type TabsTriggerProps,
} from './tabs'
// Re-export variant API from the sibling file (kept separate to avoid the
// component <-> 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/react/tabs.json