Tab Patterns

Build accessible tab interfaces using Radix UI primitives and URL-synced navigation.

Overview#

Tabs organize content into separate views. This pattern covers:

  • Basic Radix Tabs component
  • URL-synced tabs for shareable links
  • Vertical tabs layout
  • Tabs with icons
  • Animated tab indicators

Prerequisites#

npm install @radix-ui/react-tabs lucide-react

Basic Tabs#

A simple tab interface with Radix UI.

1// components/ui/Tabs.tsx 2'use client' 3 4import * as TabsPrimitive from '@radix-ui/react-tabs' 5import { forwardRef } from 'react' 6import { cn } from '@/lib/utils' 7 8export const Tabs = TabsPrimitive.Root 9 10export const TabsList = forwardRef< 11 React.ElementRef<typeof TabsPrimitive.List>, 12 React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> 13>(({ className, ...props }, ref) => ( 14 <TabsPrimitive.List 15 ref={ref} 16 className={cn( 17 'inline-flex h-10 items-center justify-center rounded-md bg-gray-100 p-1', 18 className 19 )} 20 {...props} 21 /> 22)) 23TabsList.displayName = 'TabsList' 24 25export const TabsTrigger = forwardRef< 26 React.ElementRef<typeof TabsPrimitive.Trigger>, 27 React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> 28>(({ className, ...props }, ref) => ( 29 <TabsPrimitive.Trigger 30 ref={ref} 31 className={cn( 32 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5', 33 'text-sm font-medium ring-offset-white transition-all', 34 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2', 35 'disabled:pointer-events-none disabled:opacity-50', 36 'data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm', 37 className 38 )} 39 {...props} 40 /> 41)) 42TabsTrigger.displayName = 'TabsTrigger' 43 44export const TabsContent = forwardRef< 45 React.ElementRef<typeof TabsPrimitive.Content>, 46 React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> 47>(({ className, ...props }, ref) => ( 48 <TabsPrimitive.Content 49 ref={ref} 50 className={cn( 51 'mt-4 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2', 52 className 53 )} 54 {...props} 55 /> 56)) 57TabsContent.displayName = 'TabsContent' 58 59// Usage 60<Tabs defaultValue="account"> 61 <TabsList> 62 <TabsTrigger value="account">Account</TabsTrigger> 63 <TabsTrigger value="password">Password</TabsTrigger> 64 <TabsTrigger value="notifications">Notifications</TabsTrigger> 65 </TabsList> 66 <TabsContent value="account"> 67 <AccountSettings /> 68 </TabsContent> 69 <TabsContent value="password"> 70 <PasswordSettings /> 71 </TabsContent> 72 <TabsContent value="notifications"> 73 <NotificationSettings /> 74 </TabsContent> 75</Tabs>

URL-Synced Tabs#

Sync tab state with URL for shareable links.

1// components/ui/UrlTabs.tsx 2'use client' 3 4import Link from 'next/link' 5import { usePathname } from 'next/navigation' 6import { cn } from '@/lib/utils' 7 8interface Tab { 9 href: string 10 label: string 11 icon?: React.ReactNode 12} 13 14interface UrlTabsProps { 15 tabs: Tab[] 16 className?: string 17} 18 19export function UrlTabs({ tabs, className }: UrlTabsProps) { 20 const pathname = usePathname() 21 22 return ( 23 <div className={cn('border-b', className)}> 24 <nav className="-mb-px flex gap-4"> 25 {tabs.map(tab => { 26 const isActive = pathname === tab.href 27 28 return ( 29 <Link 30 key={tab.href} 31 href={tab.href} 32 className={cn( 33 'flex items-center gap-2 border-b-2 px-1 py-3 text-sm font-medium transition-colors', 34 isActive 35 ? 'border-black text-black' 36 : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' 37 )} 38 > 39 {tab.icon} 40 {tab.label} 41 </Link> 42 ) 43 })} 44 </nav> 45 </div> 46 ) 47} 48 49// Usage in a settings page 50import { User, Lock, Bell, CreditCard } from 'lucide-react' 51 52<UrlTabs 53 tabs={[ 54 { href: '/settings', label: 'General', icon: <User className="h-4 w-4" /> }, 55 { href: '/settings/security', label: 'Security', icon: <Lock className="h-4 w-4" /> }, 56 { href: '/settings/notifications', label: 'Notifications', icon: <Bell className="h-4 w-4" /> }, 57 { href: '/settings/billing', label: 'Billing', icon: <CreditCard className="h-4 w-4" /> } 58 ]} 59/>

Vertical Tabs#

A vertical tab layout for sidebar navigation.

1// components/ui/VerticalTabs.tsx 2'use client' 3 4import * as TabsPrimitive from '@radix-ui/react-tabs' 5import { cn } from '@/lib/utils' 6 7interface Tab { 8 value: string 9 label: string 10 icon?: React.ReactNode 11} 12 13interface VerticalTabsProps { 14 tabs: Tab[] 15 defaultValue: string 16 children: React.ReactNode 17} 18 19export function VerticalTabs({ tabs, defaultValue, children }: VerticalTabsProps) { 20 return ( 21 <TabsPrimitive.Root 22 defaultValue={defaultValue} 23 orientation="vertical" 24 className="flex gap-8" 25 > 26 <TabsPrimitive.List className="flex w-48 flex-col gap-1"> 27 {tabs.map(tab => ( 28 <TabsPrimitive.Trigger 29 key={tab.value} 30 value={tab.value} 31 className={cn( 32 'flex items-center gap-3 rounded-md px-3 py-2 text-left text-sm font-medium', 33 'text-gray-600 hover:bg-gray-100 hover:text-gray-900', 34 'data-[state=active]:bg-gray-100 data-[state=active]:text-gray-900' 35 )} 36 > 37 {tab.icon} 38 {tab.label} 39 </TabsPrimitive.Trigger> 40 ))} 41 </TabsPrimitive.List> 42 <div className="flex-1">{children}</div> 43 </TabsPrimitive.Root> 44 ) 45} 46 47// Usage 48<VerticalTabs 49 defaultValue="profile" 50 tabs={[ 51 { value: 'profile', label: 'Profile', icon: <User className="h-4 w-4" /> }, 52 { value: 'account', label: 'Account', icon: <Settings className="h-4 w-4" /> }, 53 { value: 'billing', label: 'Billing', icon: <CreditCard className="h-4 w-4" /> } 54 ]} 55> 56 <TabsContent value="profile"> 57 <ProfileForm /> 58 </TabsContent> 59 <TabsContent value="account"> 60 <AccountForm /> 61 </TabsContent> 62 <TabsContent value="billing"> 63 <BillingInfo /> 64 </TabsContent> 65</VerticalTabs>

Animated Tab Indicator#

Add a smooth sliding indicator to tabs.

1// components/ui/AnimatedTabs.tsx 2'use client' 3 4import { useState, useRef, useEffect } from 'react' 5import { cn } from '@/lib/utils' 6 7interface Tab { 8 id: string 9 label: string 10} 11 12interface AnimatedTabsProps { 13 tabs: Tab[] 14 activeTab: string 15 onChange: (id: string) => void 16} 17 18export function AnimatedTabs({ tabs, activeTab, onChange }: AnimatedTabsProps) { 19 const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }) 20 const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map()) 21 22 useEffect(() => { 23 const activeTabElement = tabRefs.current.get(activeTab) 24 if (activeTabElement) { 25 setIndicatorStyle({ 26 left: activeTabElement.offsetLeft, 27 width: activeTabElement.offsetWidth 28 }) 29 } 30 }, [activeTab]) 31 32 return ( 33 <div className="relative"> 34 <div className="flex gap-1 rounded-lg bg-gray-100 p-1"> 35 {tabs.map(tab => ( 36 <button 37 key={tab.id} 38 ref={el => { 39 if (el) tabRefs.current.set(tab.id, el) 40 }} 41 onClick={() => onChange(tab.id)} 42 className={cn( 43 'relative z-10 rounded-md px-4 py-2 text-sm font-medium transition-colors', 44 activeTab === tab.id 45 ? 'text-gray-900' 46 : 'text-gray-600 hover:text-gray-900' 47 )} 48 > 49 {tab.label} 50 </button> 51 ))} 52 </div> 53 54 {/* Animated indicator */} 55 <div 56 className="absolute top-1 h-[calc(100%-8px)] rounded-md bg-white shadow-sm transition-all duration-200" 57 style={{ 58 left: indicatorStyle.left, 59 width: indicatorStyle.width 60 }} 61 /> 62 </div> 63 ) 64}

Tabs with Badge#

Show counts or status indicators on tabs.

1// components/TabWithBadge.tsx 2interface TabWithBadgeProps { 3 label: string 4 count?: number 5 active?: boolean 6 onClick: () => void 7} 8 9export function TabWithBadge({ label, count, active, onClick }: TabWithBadgeProps) { 10 return ( 11 <button 12 onClick={onClick} 13 className={cn( 14 'flex items-center gap-2 border-b-2 px-3 py-3 text-sm font-medium', 15 active 16 ? 'border-blue-600 text-blue-600' 17 : 'border-transparent text-gray-500 hover:text-gray-700' 18 )} 19 > 20 {label} 21 {count !== undefined && ( 22 <span 23 className={cn( 24 'rounded-full px-2 py-0.5 text-xs', 25 active ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600' 26 )} 27 > 28 {count} 29 </span> 30 )} 31 </button> 32 ) 33} 34 35// Usage 36<div className="flex border-b"> 37 <TabWithBadge label="All" count={45} active={tab === 'all'} onClick={() => setTab('all')} /> 38 <TabWithBadge label="Active" count={12} active={tab === 'active'} onClick={() => setTab('active')} /> 39 <TabWithBadge label="Completed" count={33} active={tab === 'completed'} onClick={() => setTab('completed')} /> 40</div>

Best Practices#

  1. Use URL tabs for important views - Enable bookmarking and sharing
  2. Keep tab labels short - Concise labels improve scannability
  3. Show loading states - Indicate when tab content is loading
  4. Lazy load tab content - Only render active tab content when possible
  5. Maintain scroll position - Consider preserving scroll within tabs