UX Standards — 05: Tab Navigation
Governs: Page-level tab bars that switch between sub-sections of a page.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Visual Anatomy
┌─────────────────────────────────────────────────────────────────┐
│ [Product Types] [Product Categories] [Attributes] [Extras] │
│ ─────────────── (active underline) │
└─────────────────────────────────────────────────────────────────┘
│ Tab content (flex-1, overflow-auto) │
Two Tab Patterns
| Pattern | When to Use | Component | URL Changes? |
|---|---|---|---|
| State-based | Tab content lives in same page, no bookmarking needed | TabbedPageLayout | No |
| Route-based | Each tab is a separate URL/route, shareable links | RouteTabbedLayout | Yes |
Pattern 1 — State-Based Tabs (TabbedPageLayout)
Use when tabs share a parent page and don't need individual URLs.
// src/components/tabbed-page-layout.tsx
import { TabbedPageLayout } from '@/components/tabbed-page-layout';
interface TabConfig {
id: string;
label: string;
content: React.ReactNode;
}
const tabs: TabConfig[] = [
{ id: 'product-types', label: 'Product Types', content: <ProductTypesTab /> },
{ id: 'product-categories', label: 'Product Categories', content: <ProductCategoriesTab /> },
{ id: 'attributes', label: 'Attribute Definitions', content: <AttributesTab /> },
{ id: 'product-extras', label: 'Product Extras', content: <ProductExtrasTab /> },
];
export default function ProductSetupPage() {
const [activeTab, setActiveTab] = useState('product-types');
return (
<TabbedPageLayout
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
);
}
With dynamic imports (lazy load each tab's page component):
const ProductTypesPage = dynamic(
() => import('./product-types/product-types-page'),
{ ssr: false }
);
const tabs: TabConfig[] = [
{ id: 'product-types', label: 'Product Types', content: <ProductTypesPage /> },
// ...
];
Pattern 2 — Route-Based Tabs (RouteTabbedLayout)
Use when each tab has its own URL so users can bookmark and share links.
// src/components/route-tabbed-layout.tsx
import { RouteTabbedLayout } from '@/components/route-tabbed-layout';
interface RouteTab {
id: string;
label: string;
href: string;
}
const tabs: RouteTab[] = [
{ id: 'overview', label: 'Overview', href: '/channels/connectors' },
{ id: 'health', label: 'Health', href: '/channels/connectors/health' },
{ id: 'logs', label: 'API Logs', href: '/channels/connectors/logs' },
];
export default function ConnectorLayout({ children }: { children: React.ReactNode }) {
return (
<RouteTabbedLayout tabs={tabs}>
{children}
</RouteTabbedLayout>
);
}
Active tab detection uses longest-match: the tab whose href is the longest prefix of the current pathname. This prevents /channels/connectors from matching when on /channels/connectors/health.
Tab Bar Container
// The tab strip itself — identical in both patterns
<div
className="flex gap-1 border-b bg-card px-2"
role="tablist"
aria-label="Page sections"
>
{tabs.map(tab => (
<TabButton key={tab.id} tab={tab} isActive={activeTab === tab.id} />
))}
</div>
| Property | Value |
|---|---|
| Layout | flex gap-1 (4px between tabs) |
| Background | bg-card |
| Bottom border | border-b (draws the baseline under all tabs) |
| Horizontal padding | px-2 |
| Role | role="tablist" |
Tab Button
// Active tab button
<button
role="tab"
aria-selected="true"
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 border-primary text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{label}
</button>
// Inactive tab button
<button
role="tab"
aria-selected="false"
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 border-transparent text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{label}
</button>
| State | Key Classes |
|---|---|
| Active | border-b-2 border-primary text-primary font-medium |
| Inactive | border-b-2 border-transparent text-muted-foreground |
| Inactive hover | hover:text-foreground hover:bg-accent/50 |
| Padding | px-3 py-2 |
| Font size | text-sm |
| Transition | transition-colors |
| Focus | focus-visible:ring-2 focus-visible:ring-ring |
The border-b-2 on the tab is the active indicator. It overlaps the border-b on the container to appear as an underline beneath the active tab.
Tab Content Area
// Wraps the content rendered by the active tab
<div
role="tabpanel"
className="flex-1 overflow-auto"
>
{activeTabContent}
</div>
| Property | Value |
|---|---|
flex-1 | Fills all remaining vertical space |
overflow-auto | Tab content scrolls internally |
role="tabpanel" | Accessibility |
Tab with Badge Count
When a tab shows a count of items (e.g., unread, pending):
<button role="tab" className={tabClasses}>
Pending Faxes
{count > 0 && (
<span className="ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 text-xs font-medium text-primary">
{count > 99 ? '99+' : count}
</span>
)}
</button>
Full Example — Product Setup Page (Orchestrate)
// src/app/(dashboard)/master-data/product-setup/page.tsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { TabbedPageLayout } from '@/components/tabbed-page-layout';
const PAGE_TITLE = 'Product Setup'; // Registered in pageTitleMap
const ProductTypesPage = dynamic(() => import('./product-types/product-types-page'), { ssr: false });
const ProductCategoriesPage = dynamic(() => import('./product-categories/product-categories-page'), { ssr: false });
const AttributeDefinitionsPage = dynamic(() => import('./attribute-definitions/attribute-definitions-page'), { ssr: false });
const ProductExtrasPage = dynamic(() => import('./product-extras/product-extras-page'), { ssr: false });
const TABS = [
{ id: 'product-types', label: 'Product Types', content: <ProductTypesPage /> },
{ id: 'product-categories', label: 'Product Categories', content: <ProductCategoriesPage /> },
{ id: 'attributes', label: 'Attribute Definitions', content: <AttributeDefinitionsPage /> },
{ id: 'product-extras', label: 'Product Extras', content: <ProductExtrasPage /> },
];
export default function ProductSetupPage() {
const [activeTab, setActiveTab] = useState(TABS[0].id);
return <TabbedPageLayout tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />;
}
Layout Integration
Tabs always appear before the toolbar and table in the content stack:
<div className="flex h-full flex-col space-y-4">
{stats && <StatsCards />}
{/* Tabs come here — before toolbar */}
<TabbedPageLayout ... />
{/* OR: tabs are the preToolbarContent in DocTypeListPage */}
<DocTypeListPage
preToolbarContent={<RouteTabbedLayout tabs={tabs}><ChildPage /></RouteTabbedLayout>}
...
/>
</div>
shadcn Tabs Alternative
For simpler cases (non-full-page tabs, inline component tabs), use shadcn Tabs:
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="overview">...</TabsContent>
<TabsContent value="details">...</TabsContent>
</Tabs>
Use shadcn Tabs for: detail card tabs, form section tabs, inline sub-tabs.
Use TabbedPageLayout / RouteTabbedLayout for: top-level page section tabs spanning the full content area.
Violation Checklist
- Tab bar uses
flex gap-1 border-b bg-card px-2 - Active tab:
border-b-2 border-primary text-primary font-medium - Inactive tab:
border-b-2 border-transparent text-muted-foreground - Inactive tab hover:
hover:text-foreground hover:bg-accent/50 - Tab container has
role="tablist" - Each tab has
role="tab"andaria-selected - Tab content has
role="tabpanel"andflex-1 overflow-auto - State-based tabs: use
TabbedPageLayoutwithuseState - Route-based tabs: use
RouteTabbedLayoutwithLinkcomponents - Dynamic imports used when tab content is a full page component
- Tabs appear ABOVE the toolbar, not below it