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 Vue ->Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/vertical-tabs.json $ npx shadcn@latest add https://uipkge.dev/r/react/vertical-tabs.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/vertical-tabs.json $ bunx shadcn@latest add https://uipkge.dev/r/react/vertical-tabs.json Named registry:
npx shadcn@latest add @uipkge-react/vertical-tabs Installs to: components/ui/vertical-tabs/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
animated Enable sliding active indicator (default true). When false, active chrome paints on the trigger. | boolean | — | optional |
npm dependencies
Files installed (2)
-
components/ui/vertical-tabs/vertical-tabs.tsx 7.6 kB
'use client' import * as React from 'react' import * as TabsPrimitive from '@radix-ui/react-tabs' import { cn } from '@/lib/utils' const VerticalTabs = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Root>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> >(({ className, ...props }, ref) => ( <TabsPrimitive.Root ref={ref} data-uipkge="" data-slot="vertical-tabs" orientation="vertical" className={cn('flex w-full gap-6', className)} {...props} /> )) VerticalTabs.displayName = 'VerticalTabs' export interface VerticalTabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> { /** Enable sliding active indicator (default true). When false, active chrome paints on the trigger. */ animated?: boolean } const VerticalTabsList = React.forwardRef<React.ElementRef<typeof TabsPrimitive.List>, VerticalTabsListProps>( ({ className, animated = true, children, ...props }, ref) => { 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="vertical-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() // Full active surface slides (muted pill); primary rail is nested absolute so it stays inset-y-1. setIndicatorStyle({ width: activeRect.width, height: activeRect.height, transform: `translate3d(${left}px, ${top}px, 0)`, opacity: 1, transition, }) firstPosition.current = false }, [animated, motionSafeTransition]) // Bind observers once per animated flip — 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="vertical-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="vertical-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, updateIndicator]) return ( <TabsPrimitive.List ref={setRefs} data-uipkge="" data-slot="vertical-tabs-list" data-animated={animated ? 'true' : 'false'} className={cn( 'group/list border-border relative flex w-56 shrink-0 flex-col gap-0.5 border-r pr-3', className, )} {...props} > {animated && ( <span data-slot="vertical-tabs-indicator" aria-hidden="true" className="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 className="bg-primary absolute inset-y-1 left-0 w-0.5 rounded-full" /> </span> )} {children} </TabsPrimitive.List> ) }, ) VerticalTabsList.displayName = 'VerticalTabsList' export interface VerticalTabsSectionProps extends React.HTMLAttributes<HTMLDivElement> { label: string } const VerticalTabsSection = React.forwardRef<HTMLDivElement, VerticalTabsSectionProps>( ({ className, label, ...props }, ref) => ( <div ref={ref} data-uipkge="" data-slot="vertical-tabs-section" className={cn( 'text-muted-foreground mt-3 mb-1 px-2 text-xs font-medium tracking-wider uppercase first:mt-0', className, )} {...props} > {label} </div> ), ) VerticalTabsSection.displayName = 'VerticalTabsSection' const VerticalTabsTrigger = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> >(({ className, ...props }, ref) => ( <TabsPrimitive.Trigger ref={ref} data-uipkge="" data-slot="vertical-tabs-trigger" className={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', className, )} {...props} /> )) VerticalTabsTrigger.displayName = 'VerticalTabsTrigger' const VerticalTabsContent = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( <TabsPrimitive.Content ref={ref} data-uipkge="" data-slot="vertical-tabs-content" className={cn( 'ring-offset-background focus-visible:ring-ring/50 flex-1 focus-visible:ring-2 focus-visible:outline-none', className, )} {...props} /> )) VerticalTabsContent.displayName = 'VerticalTabsContent' export { VerticalTabs, VerticalTabsList, VerticalTabsSection, VerticalTabsTrigger, VerticalTabsContent } -
components/ui/vertical-tabs/index.ts 0.2 kB
export { VerticalTabs, VerticalTabsList, VerticalTabsSection, VerticalTabsTrigger, VerticalTabsContent, type VerticalTabsListProps, type VerticalTabsSectionProps, } from './vertical-tabs'
Raw manifest: https://uipkge.dev/r/react/vertical-tabs.json