Skip to main content

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

PatternWhen to UseComponentURL Changes?
State-basedTab content lives in same page, no bookmarking neededTabbedPageLayoutNo
Route-basedEach tab is a separate URL/route, shareable linksRouteTabbedLayoutYes

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>
PropertyValue
Layoutflex gap-1 (4px between tabs)
Backgroundbg-card
Bottom borderborder-b (draws the baseline under all tabs)
Horizontal paddingpx-2
Rolerole="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>
StateKey Classes
Activeborder-b-2 border-primary text-primary font-medium
Inactiveborder-b-2 border-transparent text-muted-foreground
Inactive hoverhover:text-foreground hover:bg-accent/50
Paddingpx-3 py-2
Font sizetext-sm
Transitiontransition-colors
Focusfocus-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>
PropertyValue
flex-1Fills all remaining vertical space
overflow-autoTab 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" and aria-selected
  • Tab content has role="tabpanel" and flex-1 overflow-auto
  • State-based tabs: use TabbedPageLayout with useState
  • Route-based tabs: use RouteTabbedLayout with Link components
  • Dynamic imports used when tab content is a full page component
  • Tabs appear ABOVE the toolbar, not below it