UI Design Guidelines
Standard page shell pattern, component conventions, and anti-patterns for the MCM UI frontend.
MCM UI — Design Guidelines
Every page in the MCM UI follows one consistent layout structure. The accounts list page at components/features/enterprise/accounts/accounts-page-client.tsx is the canonical reference implementation.
Standard Page Shell Pattern
Layer Order (outside → inside)
1. getPageContainerClasses() ← full-height scroll container
2. getPageContentClasses() + !p-0 ← strips default padding; RouteTabShell owns it
3. <Tabs> ← shadcn Tabs context
4. <RouteTabShell tabs={…}> ← sticky top-bar + padded body (px-6 py-5 space-y-6)
5. <TopLevelTabsNav …/> ← rendered inside the `tabs` prop
6. <PageHeader …/> ← first child in the body
7. <TabsContent value="…"> ← one per tab; className={ROUTE_FLOW_TAB_CONTENT_CLASSNAME}
8. {content}Minimal Template
"use client"
import { SomeIcon } from "lucide-react"
import { Tabs, TabsContent } from "@/components/ui/tabs"
import { RouteTabShell, ROUTE_FLOW_TAB_CONTENT_CLASSNAME } from "@/components/ui/route-tab-shell"
import { TopLevelTabsNav } from "@/components/ui/top-level-tabs-nav"
import { PageHeader } from "@/components/ui/page-header"
import { getPageContainerClasses, getPageContentClasses } from "@/lib/design-tokens"
import { useRouteTabState } from "@/lib/use-route-tab-state"
import { cn } from "@/lib/utils"
const TAB_ITEMS = [
{ value: "all", label: "Label", icon: SomeIcon },
]
export function MyPageClient() {
const tabState = useRouteTabState({ tabs: ["all"], defaultTab: "all" })
return (
<div className={getPageContainerClasses()}>
<div className={cn(getPageContentClasses(), "!p-0")}>
<Tabs value={tabState.activeTab} onValueChange={tabState.setActiveTab} className="w-full">
<RouteTabShell
tabs={
<TopLevelTabsNav
items={TAB_ITEMS}
activeValue={tabState.activeTab}
variant="accounts-shell"
/>
}
>
<PageHeader
title="Page Title"
description="Short description of this section."
className="gap-3 pb-4"
actions={<Button>Primary Action</Button>}
/>
<TabsContent value="all" className={ROUTE_FLOW_TAB_CONTENT_CLASSNAME}>
{/* content */}
</TabsContent>
</RouteTabShell>
</Tabs>
</div>
</div>
)
}Key Rules
1. Always use !p-0 on the content wrapper
getPageContentClasses() adds default padding. The !p-0 override removes it so RouteTabShell controls its own padding (px-6 py-5 space-y-6). Without !p-0 you get double-padding.
// Correct
<div className={cn(getPageContentClasses(), "!p-0")}>
// Wrong — double padding
<div className={getPageContentClasses()}>2. PageHeader goes inside RouteTabShell, not outside <Tabs>
// Correct
<RouteTabShell tabs={…}>
<PageHeader title="…" />
<TabsContent …>
// Wrong — outside Tabs entirely
<div className="px-6 py-5">
<PageHeader title="…" />
</div>
<Tabs>…</Tabs>3. Always use variant="accounts-shell" for TopLevelTabsNav
Both "accounts-shell" and "route-shell" render the same visually, but "accounts-shell" is the project standard.
4. Use ROUTE_FLOW_TAB_CONTENT_CLASSNAME on every TabsContent
This exports "mt-0" which prevents extra top margin stacking with RouteTabShell's space-y-6.
5. Single-tab pages still get the full shell
Even if there's only one tab, still use RouteTabShell + TopLevelTabsNav. This keeps layout consistent and makes it trivial to add more tabs later.
6. Page-level tabs must not be managed inside child components
If a child component (e.g., UserManagement) renders its own Tabs + TopLevelTabsNav, those tabs belong in the page-client's RouteTabShell. The component should accept tab context from the parent or render TabsContent blocks directly.
7. KPI stats use InteractiveKpiCard in a 4-column grid
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<InteractiveKpiCard title="…" value={…} subtitle="…" icon={<Icon className="h-4 w-4" />} />
</div>Do not use custom Card components for stats.
8. Use DataTable for data tables
DataTable from components/ui/data-table.tsx provides built-in search, column filters, pagination, and row click handling.
// Correct
<DataTable
data={items}
columns={columns}
searchKey="name"
onRowClick={(item) => router.push(`/items/${item.id}`)}
/>
// Wrong — manual table + separate search card
<Card><Input … /></Card>
<Card><Table>…</Table></Card>9. No "Open" / "View" action column when rows are already clickable
If onRowClick navigates to a detail page, do not add a redundant "Open" button column.
10. Breadcrumbs are global — do not add per-page
Breadcrumbs is already rendered in ConsoleLayout. Never add it inside individual page components.
Component Reference
| Component | Path | Purpose |
|---|---|---|
RouteTabShell | components/ui/route-tab-shell.tsx | Sticky tab-bar + padded body shell |
ROUTE_FLOW_TAB_CONTENT_CLASSNAME | same file | "mt-0" — use on every TabsContent |
TopLevelTabsNav | components/ui/top-level-tabs-nav.tsx | Horizontal tab navigation bar |
PageHeader | components/ui/page-header.tsx | Title + description + optional action button(s) |
InteractiveKpiCard | components/ui/interactive-surfaces.tsx | Gradient KPI stat card with hover effect |
DataTable | components/ui/data-table.tsx | Full-featured table with search, filters, pagination |
getPageContainerClasses | lib/design-tokens.ts | Outer scroll container classes |
getPageContentClasses | lib/design-tokens.ts | Inner content wrapper classes (always add !p-0) |
useRouteTabState | lib/use-route-tab-state.ts | Tab state synced to URL query params |
Detail Pages (with multiple sub-tabs)
Detail pages (e.g., /accounts/[id], /budget/[id]) follow the same shell pattern. Additional metadata (badges, status pills, progress bars) goes inside the RouteTabShell body, before the TabsContent blocks, grouped in a <div className="space-y-3">.
<RouteTabShell tabs={<TopLevelTabsNav … />}>
<div className="space-y-3">
{/* Metadata badges */}
<div className="flex items-center gap-2">
<Badge>…</Badge>
</div>
<PageHeader title={item.name} description={item.description} className="gap-3 pb-0" />
{/* Optional inline status/progress */}
<div className="flex items-center gap-3">…</div>
</div>
<TabsContent value="overview" className={ROUTE_FLOW_TAB_CONTENT_CLASSNAME}>…</TabsContent>
<TabsContent value="settings" className={ROUTE_FLOW_TAB_CONTENT_CLASSNAME}>…</TabsContent>
</RouteTabShell>Anti-Patterns
| Don't do this | Do this instead |
|---|---|
<div className="p-6 space-y-6"> as page root | Use getPageContainerClasses() + !p-0 shell |
<h1> or <h2> as page/section title | Use <PageHeader title="…" /> |
getPageContentClasses() without !p-0 | Always add cn(getPageContentClasses(), "!p-0") |
RouteSubTabsNav as the main page tab bar | Use TopLevelTabsNav inside RouteTabShell |
Custom Card stats grid | Use InteractiveKpiCard in a 4-col grid |
Raw <Table> or shadcn <Table> for data | Use <DataTable> |
Separate search <Input> + filter <Select> | Use DataTable's built-in searchKey + filterable columns |
| "Open" / "View" button column when row is clickable | Use onRowClick only, remove the button |
TopLevelTabsNav variant "route-shell" | Use variant="accounts-shell" |
| Managing page-level tabs inside a child component | Lift tab state to the page-client |
Reference Implementations
| Page | File |
|---|---|
| Accounts list (canonical) | components/features/enterprise/accounts/accounts-page-client.tsx |
| Account detail (canonical) | app/(main)/(enterprise)/accounts/[id]/page-client.tsx |
| Budget list | app/(main)/(enterprise)/(manage)/budget/page.tsx |
| Budget detail | app/(main)/(enterprise)/(manage)/budget/[id]/page-client.tsx |
| User Management (lifted tabs) | components/features/enterprise/user-management/user-management-page-client.tsx |
| Dashboards | components/features/enterprise/dashboards/dashboards-page-client.tsx |