saas-ui-patterns
Premium SaaS UI/UX patterns for JKKN COE Next.js CRUD pages. Use when creating new pages, redesigning existing pages, or ensuring UI consistency across the application. Triggers on "create page", "new
SKILL.md.claude/skills/saas-ui-patterns/SKILL.md
GitHubSaaS UI Patterns
Standard UI/UX patterns for all JKKN COE CRUD/table pages.
Reference implementations:
app/(coe)/grading/grades/page.tsx(canonical)app/(coe)/master/courses/page.tsxapp/(coe)/exam-management/examiners/page.tsx
Required Imports
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
Never import: AlertDialogTrigger, SheetDescription
Lucide icons: MoreHorizontal, ChevronDown, RefreshCw, Download, Upload, FileSpreadsheet, PlusCircle, Edit, Trash2, Search, ChevronLeft, ChevronRight
Component Patterns
For complete code snippets, see references/patterns.md.
0. Page Layout (Outer Shell)
<SidebarProvider>
<AppSidebar />
<SidebarInset className="flex flex-col min-h-screen">
<AppHeader />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 overflow-y-auto">
{/* Breadcrumb */}
{/* Scorecards */}
{/* Table Card */}
</div>
</SidebarInset>
</SidebarProvider>
Never use:
- Fixed height:
h-[calc(100vh-...)]on the outer div PageTransitionwrapper (removed)AppFooterinside SidebarInsetoverflow-hiddenon SidebarInset
1. Scorecards (border-l-4)
Use <Card> with border-l-4 border-l-{color}-500 hover:shadow-md transition-shadow. Colors: blue, emerald, amber, purple, green, rose, teal. Numbers: text-2xl font-bold tracking-tight. Labels: text-xs font-medium text-muted-foreground mt-0.5. Icon: h-5 w-5 text-{color}-500/40 (right-aligned). Grid: grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 flex-shrink-0.
Scorecard counts must use institution-filtered data, NOT raw data or search-filtered data:
// Institution-filtered base — for scorecards only (no search/status filter applied)
const institutionFiltered = useMemo(() => {
if (shouldFilter && myjkknInstitutionIds?.length > 0) {
return allItems.filter(s => myjkknInstitutionIds.includes(s.institution?.id))
}
return allItems
}, [allItems, shouldFilter, myjkknInstitutionIds])
// Table data = institutionFiltered + search + status
const filtered = useMemo(() => {
let result = institutionFiltered
// apply search/status filters...
return result
}, [institutionFiltered, searchTerm, activeFilter])
// Scorecards derived from institutionFiltered (not allItems, not filtered)
const totalCount = institutionFiltered.length
const activeCount = institutionFiltered.filter(s => s.is_active).length
Behaviour:
- All Institutions selected → scorecards show totals for all records
- Specific institution selected → scorecards update to that institution's counts
- Search/status toolbar filters do not affect scorecard numbers
<Card className="border-l-4 border-l-blue-500 hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-2xl font-bold tracking-tight">{count}</p>
<p className="text-xs font-medium text-muted-foreground mt-0.5">Label</p>
</div>
<Icon className="h-5 w-5 text-blue-500/40" />
</div>
</CardContent>
</Card>
Never use:
grid-cols-2 md:grid-cols-4 gap-4withoutflex-shrink-0- Icon container divs:
h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center - Colored number text:
text-green-700,text-yellow-700,text-slate-900on the number text-sm text-slate-600labels (usetext-xs font-medium text-muted-foreground)plain divs with colored backgrounds,rounded-2xl,shadow-sm,ring-1,font-groteskflex items-center gap-3layout (useflex items-center justify-between)
2. Table Card
Wrap in <TooltipProvider delayDuration={300}>. Card: flex-1 flex flex-col min-h-0. Table is inside a bounded div with min/max height — the page scrolls, not the full card.
<TooltipProvider delayDuration={300}>
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader className="flex-shrink-0 px-4 py-3 border-b">
{/* Toolbar */}
</CardHeader>
<CardContent className="px-4 pb-4 pt-0 flex-1 flex flex-col min-h-0">
<div className="rounded-md border flex-1 overflow-hidden mt-3 min-h-[380px] max-h-[520px]">
<div className="h-full overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50">
{/* headers: text-xs font-semibold */}
</TableHeader>
<TableBody>
{/* rows: hover:bg-muted/50, cells: text-sm */}
</TableBody>
</Table>
</div>
</div>
{/* Pagination goes here, outside the bounded div */}
</CardContent>
</Card>
</TooltipProvider>
Never use:
flex-1 overflow-auto p-0on CardContent (usepx-4 pb-4 pt-0 flex-1 flex flex-col min-h-0)border-slate-200 shadow-sm rounded-2xlon main Cardrounded-xl border border-slate-200 overflow-hidden bg-whitetable wrapperbg-slate-50 border-b border-slate-200on TableHeaderbg-slate-50,border-slate-*anywhere in tablebg-slate-50/50on CardContenttext-sm font-semibold text-slate-700on TableHead (usetext-xs font-semibold)font-groteskon cellstext-muted-foregroundon table data cells- inline
style={{ minHeight, maxHeight }}(use Tailwindmin-h-[380px] max-h-[520px])
3. Toolbar (CardHeader)
CardHeader className="flex-shrink-0 px-4 py-3 border-b"
Row 1 (title + actions): flex items-center justify-between
- Title:
text-base font-semibold+text-xs text-muted-foreground - Actions:
flex items-center gap-1.5
Row 2 (filters + search): flex items-center gap-2 flex-wrap
- Filter Selects:
h-8 text-sm(noborder-slate-*,focus:border-emerald-*,rounded-lg) - Search:
pl-8 h-8 text-smwithflex-1 max-w-smorlg:w-[260px] - Refresh: icon-only
h-8 w-8 p-0wrapped in<Tooltip> - Export:
<DropdownMenu>with Template/Excel/JSON items, buttonh-8 text-sm px-3, contentw-44 - Import:
h-8 text-sm px-3 - Add:
h-8 text-sm px-4
Never use:
px-8 py-6on CardHeader (alwayspx-4 py-3)- Large icon + title block:
h-12 w-12 rounded-xl bg-emerald-50+text-xl font-bold text-slate-900 space-y-4between rows (usespace-y-3)h-9on any filter/action button (alwaysh-8)rounded-lgon filter SelectTriggerrounded-xl bg-emerald-600 hover:bg-emerald-700 text-whiteon Add button- Gradient headers (
bg-gradient-to-r),font-grotesk,text-xl font-bold
4. Action Column (MoreHorizontal)
Replace inline Edit/Delete with <DropdownMenu> triggered by MoreHorizontal h-4 w-4. Button: h-7 w-7 p-0. Delete item: text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/20. Sets deleteTarget state.
5. Standalone Delete AlertDialog
const [deleteTarget, setDeleteTarget] = useState<Entity | null>(null)
Controlled by deleteTarget state, placed OUTSIDE the table (after Sheet, before SidebarProvider close). Never nest AlertDialogTrigger inside table rows.
6. Dynamic Pagination
const [itemsPerPage, setItemsPerPage] = useState(10) // plain number, never "all" union
pageSizeOptions useMemo with smart filtering. "All" only when total > default. effectivePerPage for calculations. Pagination UI inside CardContent, below table:
<div className="flex items-center justify-between pt-3 px-4 pb-3 border-t">
Page size: <Select> with h-7 w-[70px] text-xs. Nav: ChevronLeft/ChevronRight buttons h-7 w-7 p-0. Page indicator: text-xs text-muted-foreground px-2 tabular-nums.
Never use: First/Previous/Next/Last text buttons, page number buttons, number | "all" type.
7. Global Institution Filter
Every page must respect the global institution selector in the sidebar. Use useInstitutionFilter() from @/hooks/use-institution-filter.
const {
mustSelectInstitution, // true = "All Institutions" selected (super_admin)
shouldFilter, // true = filter is active (specific institution selected)
myjkknInstitutionIds, // MyJKKN institution UUIDs for current institution
institutionId, // COE institution UUID
isReady, // wait for this before fetching
} = useInstitutionFilter()
Institution column in table — show only when "All Institutions" selected:
// Header
{mustSelectInstitution && <TableHead className="text-xs font-semibold">Institution</TableHead>}
// Cell
{mustSelectInstitution && <TableCell className="text-sm">{row.institution_code}</TableCell>}
// colSpan in loading/empty rows — adjust total column count
colSpan={mustSelectInstitution ? 9 : 8}
COE data filtering — use appendToUrl or filterData:
// Fetch with institution filter in URL
useEffect(() => {
if (!isReady) return
const url = appendToUrl('/api/entity')
fetch(url).then(...)
}, [isReady, filter])
// Or filter local array
const filtered = filterData(allItems, 'institution_code')
MyJKKN data filtering — filter client-side by myjkknInstitutionIds:
// In useMemo — filter staff/learners from MyJKKN by institution
if (shouldFilter && myjkknInstitutionIds?.length > 0) {
result = result.filter(s => myjkknInstitutionIds.includes(s.institution?.id))
}
Reset page on institution change:
useEffect(() => { setCurrentPage(1) }, [shouldFilter, myjkknInstitutionIds])
Role behaviour:
| User | mustSelectInstitution |
shouldFilter |
Effect |
|---|---|---|---|
| Normal user | false |
true |
Auto-filtered to own institution |
| super_admin (All) | true |
false |
All data shown, Institution column visible |
| super_admin (specific) | false |
true |
Filtered to selected institution |
Never use:
- A local institution dropdown filter in the toolbar (global filter handles this)
- Fetching without checking
isReadyfirst - Filtering only server-side (MyJKKN API often ignores server filters — always also filter client-side)
8. Form Sheet (Clean)
- Content:
sm:max-w-[720px] overflow-y-auto(orsm:max-w-[800px]for wider forms) - Header:
pb-4 border-bwithtext-lg font-semiboldtitle - Description:
<p className="text-sm text-muted-foreground">(neverSheetDescription) - Body:
<div className="mt-6 space-y-8"> - Sections:
text-xs font-semibold text-muted-foreground uppercase tracking-wider - Subsequent sections: add
pt-2 border-t - Action buttons:
<div className="flex justify-end gap-3 pt-6 border-t">with plainh-10 px-6
Never use: gradient headers, rounded-xl bg-emerald-600, border-slate-* on buttons, SheetDescription component.
8. Loading/Empty States
- Loading:
RefreshCw animate-spin h-5 w-5+text-smmessage inh-32 text-centerwithtext-muted-foreground - Empty: entity icon
h-8 w-8 opacity-20+text-smtitle +text-xssubtitle
Never use: custom spinner divs (border-2 border-primary border-t-transparent), h-10 w-10, text-muted-foreground/40.
Typography Rules
| Element | Class | Never Use |
|---|---|---|
| Card title | text-base font-semibold |
text-xl font-bold, text-lg font-bold |
| Card description | text-xs text-muted-foreground |
text-sm text-slate-600 |
| Table cells | text-sm |
text-[11px], font-grotesk |
| Table headers | text-xs font-semibold |
text-sm font-semibold text-slate-700 |
| Form labels | text-sm font-semibold |
text-sm font-medium (in form only) |
| Scorecard numbers | text-2xl font-bold tracking-tight |
text-3xl, font-grotesk, colored variants |
| Scorecard labels | text-xs font-medium text-muted-foreground mt-0.5 |
text-sm text-slate-600 |
| Badges | text-xs |
|
| Section labels | text-xs font-semibold uppercase tracking-wider |
gradient dividers |
| Sheet title | text-lg font-semibold |
text-xl font-bold |
Anti-Patterns (Never Use)
Styling
font-grotesk- never use anywheretext-[11px]or any arbitrary pixel sizesrounded-2xl,rounded-3xl,rounded-xlon cards/containersring-1 ring-*-100decorative ringsshadow-smon Card componentsborder-slate-*specific border colors (use default borders)text-slate-*specific text colors (usetext-foreground/text-muted-foreground)bg-slate-50(usebg-muted/50)bg-slate-50/50on CardContent (usep-0)px-8 py-6on CardHeader (usepx-4 py-3)focus:border-emerald-*,focus:ring-emerald-*on inputsbg-emerald-600 hover:bg-emerald-700 text-whiteon action buttonsbg-gradient-to-ron card headersh-9on filter selects or action buttons (useh-8)
Structure
AlertDialogTriggerwrapping delete buttons (use standalonedeleteTarget)SheetDescriptioncomponent (use<p>tag)- Separate Edit/Delete buttons in table rows (use MoreHorizontal dropdown)
const itemsPerPage = 10without useStateuseState<number | "all">for items per page (use plainuseState(10))- Icon container divs (
h-10 w-10 rounded-lg bg-*-50orh-12 w-12 rounded-xl bg-*-50 ring-1) - Large icon+title block in CardHeader (the
flex items-center gap-3with icon circle +text-xl font-bold) rounded-xl border border-slate-200 overflow-hidden bg-whitewrapper div around<Table>- Gradient section dividers with colored icon circles
- First/Previous/Next/Last text pagination buttons
- Page number buttons in pagination
text-muted-foregroundon table data cells