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-reactBasic 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#
- Use URL tabs for important views - Enable bookmarking and sharing
- Keep tab labels short - Concise labels improve scannability
- Show loading states - Indicate when tab content is loading
- Lazy load tab content - Only render active tab content when possible
- Maintain scroll position - Consider preserving scroll within tabs
Related Patterns#
- Navigation - Navigation patterns
- URL State - URL state management
- Layouts - Page layout patterns