UI Patterns
Component Priority
- @kit/ui first - Check packages/@kit/ui/ before building custom
- Server components default - Only
'use client'when needed - Dark mode support - Use semantic color classes
Common @kit/ui Components
// Buttons
import { Button } from '@kit/ui/button';
// Forms
import { Form, FormField, FormItem, FormLabel, FormControl } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
// Layout
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@kit/ui/dialog';
// Data Display
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@kit/ui/table';
import { Badge } from '@kit/ui/badge';
// Feedback
import { toast } from '@kit/ui/sonner';
Table Component (IMPORTANT)
The Table component from @kit/ui/table already includes a border wrapper. Do NOT wrap it with an additional <div className="rounded-md border">.
// ✅ CORRECT - Table has its own border
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map(item => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
// ❌ WRONG - Creates double border
<div className="rounded-md border">
<Table>
...
</Table>
</div>
Why: The Table component wrapper div has rounded-lg border built in (see packages/ui/src/shadcn/table.tsx:11).
Server vs Client Components
Server Component (default): Client Component ('use client'):
├─ Data fetching ├─ onClick, onChange handlers
├─ Database access ├─ useState, useEffect hooks
├─ Sensitive logic ├─ Browser APIs (localStorage)
└─ No interactivity └─ Real-time subscriptions
Form Pattern
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
const schema = z.object({
name: z.string().min(1, 'Required'),
email: z.string().email(),
});
export function MyForm({ onSubmit }) {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
});
const handleSubmit = async (data) => {
const result = await onSubmit(data);
if (result.success) {
toast.success('Saved');
form.reset();
} else {
toast.error(result.error.message);
}
};
return (
<Form {...form}>
<form className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save'}
</Button>
</form>
</Form>
);
}
Admin CRUD Page Pattern
// page.tsx (Server Component)
import { AdminPageTemplate } from '@/components/admin/admin-page-template';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { columns } from './columns';
import { loadItems } from './loader';
export default async function ItemsPage() {
const items = await loadItems();
return (
<AdminPageTemplate
title="Items"
description="Manage items"
createButton={{ label: 'Add Item', href: '/admin/items/new' }}
>
<DataTable columns={columns} data={items} />
</AdminPageTemplate>
);
}
Styling & Dark Mode
Semantic Colors (Auto Dark Mode)
Use semantic color tokens for general UI - they automatically switch in dark mode:
// ✅ DO - Semantic colors (no dark: variants needed)
className="bg-background text-foreground" // Base
className="bg-card text-card-foreground" // Cards
className="bg-muted text-muted-foreground" // Muted elements
className="bg-primary text-primary-foreground" // Primary actions
className="bg-secondary text-secondary-foreground" // Secondary
className="bg-destructive text-destructive-foreground" // Destructive
className="border-border" // Borders
className="border-input" // Input borders
// ❌ DON'T - Hardcoded colors (breaks dark mode)
className="bg-white text-black"
className="bg-slate-100 dark:bg-slate-800" // Use bg-muted instead
className="border-gray-200 dark:border-neutral-800" // Use border-border instead
Status Colors (Require dark: Variants)
For status indicators (success/warning/error/info), use hardcoded colors WITH explicit dark variants:
// ✅ DO - Status colors with dark mode variants
// Success (green)
className="bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-100 dark:border-green-700"
className="text-green-600 dark:text-green-400"
className="bg-green-50 dark:bg-green-950"
// Warning (amber/yellow)
className="bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-100 dark:border-amber-700"
className="text-amber-600 dark:text-amber-400"
// Error (red)
className="bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-100 dark:border-red-700"
className="text-red-600 dark:text-red-400"
// Info (blue)
className="bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-700"
className="text-blue-600 dark:text-blue-400"
Color-Coded Percentage Indicators
const getColorClass = (percentage: number) => {
if (percentage === 100) return 'text-green-600 dark:text-green-400';
if (percentage >= 75) return 'text-emerald-600 dark:text-emerald-400';
if (percentage >= 50) return 'text-amber-600 dark:text-amber-400';
if (percentage > 0) return 'text-orange-600 dark:text-orange-400';
return 'text-red-600 dark:text-red-400';
};
Quick Reference
| Element | Pattern |
|---|---|
| Cards | bg-card text-card-foreground border-border rounded-lg |
| Muted text | text-muted-foreground |
| Inputs | border-input placeholder:text-muted-foreground |
| Buttons | Use variants: default, outline, ghost, destructive |
| Status badges | Hardcoded colors with dark: variants |
| Icons with status | text-green-600 dark:text-green-400 |
Status Badge Pattern
Type-safe status mapping with Badge component:
import { Badge } from '@kit/ui/badge';
type Status = 'active' | 'pending' | 'completed' | 'canceled';
function getStatusVariant(status: Status): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'pending': return 'secondary';
case 'completed': return 'outline';
case 'canceled': return 'destructive';
}
}
function getStatusLabel(status: Status): string {
const labels: Record<Status, string> = {
active: 'Active',
pending: 'Pending',
completed: 'Completed',
canceled: 'Canceled',
};
return labels[status];
}
export function StatusBadge({ status }: { status: Status }) {
return (
<Badge variant={getStatusVariant(status)}>
{getStatusLabel(status)}
</Badge>
);
}
Empty State Pattern
import { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton } from '@kit/ui/empty-state';
import { FolderOpen } from 'lucide-react';
<EmptyState>
<FolderOpen className="h-12 w-12 text-muted-foreground" />
<EmptyStateHeading>No items found</EmptyStateHeading>
<EmptyStateText>Get started by creating your first item.</EmptyStateText>
<EmptyStateButton Item</EmptyStateButton>
</EmptyState>
URL-Based Filter Pattern
Filters that sync with URL search params:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from '@kit/ui/input';
import { useDebouncedCallback } from 'use-debounce';
export function TableSearchInput({ placeholder = 'Search...' }) {
const router = useRouter();
const searchParams = useSearchParams();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('search', term);
} else {
params.delete('search');
}
params.set('page', '1'); // Reset pagination
router.push(`?${params.toString()}`);
}, 300);
return (
<Input
placeholder={placeholder}
defaultValue={searchParams.get('search') ?? ''}
=> handleSearch(e.target.value)}
/>
);
}
Icon Button Pattern
Consistent icon + text button styling:
import { Button } from '@kit/ui/button';
import { Plus, Download, Trash2 } from 'lucide-react';
// Icon with text
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
// Icon only (with aria-label for accessibility)
<Button variant="ghost" size="icon" aria-label="Delete item">
<Trash2 className="h-4 w-4" />
</Button>
// Common icon sizes: h-4 w-4 (default), h-5 w-5 (larger)
Responsive Grid Pattern
// Card grid - responsive columns
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map(item => <ItemCard key={item.id} item={item} />)}
</div>
// Form layout - two columns on desktop
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField name="firstName" ... />
<FormField name="lastName" ... />
</div>
Accessibility Checklist
// ✅ Icon-only buttons need aria-label
<Button variant="ghost" size="icon" aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
// ✅ Form inputs need labels
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel> {/* Required */}
<FormControl>
<Input {...field} aria-describedby="email-hint" />
</FormControl>
<FormDescription id="email-hint">We'll never share your email.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
// ✅ Error states need aria-invalid
<Input aria-invalid={!!error} aria-describedby={error ? 'error-msg' : undefined} />
// ✅ Interactive elements need keyboard support
<div role="button" tabIndex={0} => e.key === 'Enter' && onClick()}>
Date Formatting
Use date-fns for consistent date formatting:
import { format, formatDistanceToNow } from 'date-fns';
// Display formats
format(date, 'LLL dd, y') // "Jan 15, 2025"
format(date, 'yyyy-MM-dd') // "2025-01-15"
format(date, 'HH:mm') // "14:30"
format(date, 'EEEE, MMMM d') // "Wednesday, January 15"
// Relative time
formatDistanceToNow(date, { addSuffix: true }) // "2 hours ago"
DataTable with Filters
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status',
cell: ({ row }) => <Badge>{row.original.status}</Badge>
},
{ id: 'actions', cell: ({ row }) => <RowActions item={row.original} /> },
];
<DataTable
columns={columns}
data={items}
searchKey="name"
filterableColumns={[
{ id: 'status', title: 'Status', options: ['active', 'inactive'] },
]}
/>
Admin Table Architecture
All admin tables MUST follow this consistent architecture for actions columns.
Column Layout Standard
| Primary Actions | Data Columns... | Destructive Actions |
|-----------------|------------------------|---------------------|
| [View] [Edit] | Name, Status, Date... | [Delete] |
Action Types by Position:
| Type | Position | Icon | Confirmation Required |
|---|---|---|---|
| View | First column | Eye |
No |
| Edit | First column | Pencil |
No |
| Download | First column | Download |
No |
| Delete | Last column | Trash2 |
Yes (AlertDialog) |
| Special Admin | Last column | Varies | Context-dependent |
Primary Actions Column (First)
import { Eye, Pencil, Download } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import Link from 'next/link';
// Column definition
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return (
<TooltipProvider>
<div className="flex items-center gap-1">
{/* View - always present */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/items/${item.id}`}>
<Eye className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="common:view" defaults="View" />
</TooltipContent>
</Tooltip>
{/* Edit - if applicable */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/items/${item.id}/edit`}>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="common:edit" defaults="Edit" />
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
enableHiding: false,
}
Destructive Actions Column (Last)
CRITICAL: All delete actions MUST have AlertDialog confirmation.
import { Trash2 } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip';
import { cn } from '@kit/ui/utils';
import { Trans } from '@kit/ui/trans';
// State in component
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
// Column definition (place as LAST column)
{
id: 'destructive_actions',
header: '',
cell: ({ row }) => {
const item = row.original;
const canDelete = /* your condition - e.g., no dependencies */;
return (
<TooltipProvider>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span> {/* span wrapper for disabled button tooltip */}
<Button
variant="ghost"
size="icon"
=> setItemToDelete(item)}
disabled={!canDelete}
>
<Trash2
className={cn(
'h-4 w-4',
canDelete ? 'text-destructive' : 'text-muted-foreground'
)}
aria-hidden="true"
/>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{canDelete ? (
<Trans i18nKey="common:delete" defaults="Delete" />
) : (
<Trans i18nKey="common:cannotDelete" defaults="Cannot delete" />
)}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
enableHiding: false,
}
Delete Confirmation Dialog (Required)
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Trans } from '@kit/ui/trans';
// State
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Handler
const handleDelete = async () => {
if (!itemToDelete) return;
setIsDeleting(true);
try {
const result = await deleteItemAction({ id: itemToDelete.id });
if (result.success) {
toast.success(t('admin:items.deleted'));
setItemToDelete(null);
} else {
toast.error(result.error || t('admin:items.deleteError'));
}
} catch (error) {
toast.error(t('admin:items.deleteError'));
} finally {
setIsDeleting(false);
}
};
// Dialog (render outside the table)
<AlertDialog
open={!!itemToDelete}
=> !open && setItemToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans
i18nKey="admin:items.deleteTitle"
values={{ name: itemToDelete?.name }}
/>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="admin:items.deleteDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
<Trans i18nKey="common:cancel" />
</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<Trans i18nKey="common:deleting" />
) : (
<Trans i18nKey="common:delete" />
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Complete Table Component Structure
'use client';
import { useState, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
// ... other imports
export function ItemsTable({ items, pageCount, pageSize, page, filters }) {
const { t } = useTranslation();
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => { /* ... */ };
const columns: ColumnDef<Item>[] = useMemo(() => [
// 1. PRIMARY ACTIONS (first)
{
id: 'actions',
header: '',
cell: ({ row }) => { /* View, Edit, Download buttons */ },
enableSorting: false,
enableHiding: false,
},
// 2. DATA COLUMNS (middle)
{ id: 'name', header: t('admin:items.table.name'), /* ... */ },
{ id: 'status', header: t('admin:items.table.status'), /* ... */ },
{ id: 'created', header: t('admin:items.table.created'), /* ... */ },
// 3. DESTRUCTIVE ACTIONS (last) - if applicable
{
id: 'destructive_actions',
header: '',
cell: ({ row }) => { /* Delete button */ },
enableSorting: false,
enableHiding: false,
},
], [t, handleDelete]);
return (
<>
<DataTable columns={columns} data={items} /* ... */ />
{/* Delete confirmation dialog */}
<AlertDialog open={!!itemToDelete} /* ... */}>
{/* ... */}
</AlertDialog>
</>
);
}
Mobile Responsiveness
Icon buttons with size="icon" provide:
- 44x44px minimum touch targets (WCAG compliant)
- Tooltips automatically hidden on touch devices
- Consistent spacing with
gap-1
No special mobile handling needed - the same icon buttons work on all screen sizes.
TooltipProvider Requirement
CRITICAL: Always wrap tooltip groups with <TooltipProvider>:
// ✅ CORRECT - Provider wraps all tooltips
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>...</Tooltip>
<Tooltip>...</Tooltip>
</div>
</TooltipProvider>
// ❌ WRONG - Missing provider causes runtime error
<div className="flex items-center gap-1">
<Tooltip>...</Tooltip> // Error: Tooltip must be used within TooltipProvider
</div>
The ESLint rule react-providers/require-provider enforces this at lint time.
Dialog Pattern
'use client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@kit/ui/dialog';
import { Button } from '@kit/ui/button';
import { useState } from 'react';
export function CreateDialog({ onSubmit }) {
const [open, setOpen] = useState(false);
const handleSubmit = async (data) => {
const result = await onSubmit(data);
if (result.success) {
setOpen(false);
}
};
return (
<Dialog open={open}
<DialogTrigger asChild>
<Button>Create</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Item</DialogTitle>
</DialogHeader>
<MyForm />
</DialogContent>
</Dialog>
);
}
Loading States
// Button loading
<Button disabled={isLoading}>
{isLoading ? 'Loading...' : 'Submit'}
</Button>
// Skeleton loading
import { Skeleton } from '@kit/ui/skeleton';
<Skeleton className="h-4 w-[200px]" />
Responsive Layout Patterns
Core Principles (Airbnb/Industry Best Practices)
- Mobile-first approach - Base styles for mobile, then scale up with
sm:,md:,lg: - Device-agnostic naming - Use "small", "medium", "large" not "phone", "tablet", "desktop"
- Content-driven breakpoints - Break where the design needs it, not at arbitrary device widths
- Optimal line length - 45-75 characters for readability (achieved via max-width containers)
Tailwind Breakpoints Reference
sm: 640px - Small devices (large phones, small tablets)
md: 768px - Medium devices (tablets)
lg: 1024px - Large devices (laptops)
xl: 1280px - Extra large (desktops)
2xl: 1536px - Ultra wide screens
Container Width Patterns by Page Type
CRITICAL: Always use mx-auto with max-w-* to center content on wide screens.
| Page Type | Pattern | Use Case |
|---|---|---|
| Wizards/Forms | mx-auto max-w-2xl |
Multi-step forms, settings |
| Single Forms | mx-auto max-w-3xl |
Create/edit pages |
| Profile/Detail | mx-auto max-w-4xl |
Profile pages, detail views |
| Dashboard | mx-auto max-w-6xl or full |
Data-heavy pages, tables |
| Full Width | No max-width | Admin tables, kanban boards |
// ✅ CORRECT - Centered with max-width
<PageBody>
<div className="mx-auto max-w-2xl">
<WizardForm />
</div>
</PageBody>
// ❌ WRONG - Left-aligned, looks weird on desktop
<PageBody>
<div className="max-w-2xl">
<WizardForm />
</div>
</PageBody>
Standard Page Layout Template
// page.tsx
import { PageBody, PageHeader } from '@kit/ui/page';
export default function MyPage() {
return (
<>
<PageHeader
title="Page Title"
description={<AppBreadcrumbs />}
/>
<PageBody>
{/* Choose appropriate max-width for content type */}
<div className="mx-auto max-w-4xl space-y-6">
{/* Page content */}
</div>
</PageBody>
</>
);
}
Responsive Component Patterns
Mobile-First Visibility:
// Show on mobile, hide on desktop
<div className="sm:hidden">Mobile navigation</div>
// Hide on mobile, show on desktop
<div className="hidden sm:block">Desktop navigation</div>
// Different layouts at breakpoints
<div className="flex flex-col sm:flex-row">
<Sidebar className="w-full sm:w-64" />
<Content className="flex-1" />
</div>
Responsive Grid:
// 1 col mobile → 2 cols tablet → 3 cols desktop
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map(item => <Card key={item.id} />)}
</div>
// Two-column form on desktop
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField name="firstName" />
<FormField name="lastName" />
</div>
Responsive Spacing:
// Tighter spacing on mobile
<div className="space-y-4 sm:space-y-6 lg:space-y-8">
// Responsive padding
<div className="p-4 sm:p-6 lg:p-8">
// Responsive text sizes
<h1 className="text-xl sm:text-2xl lg:text-3xl">
Dialog Responsive Patterns
// Full screen on mobile, constrained on desktop
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-lg">
// Wider dialog for complex content
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-2xl">
Wizard/Stepper Responsive Pattern
Use the unified MobileWizardLayout components for all wizard/stepper flows:
import { MobileWizardLayout } from '@kit/ui/mobile-wizard-layout';
import { WizardNavigation } from '@kit/ui/wizard-navigation';
import { WizardProgress } from '@kit/ui/wizard-progress';
MobileWizardLayout - Main wrapper with three slots:
<MobileWizardLayout
contentWidth="lg" // 'md' | 'lg' | 'xl' | 'full'
header={<WizardProgress ... />}
navigation={<WizardNavigation ... />}
>
{stepContent}
</MobileWizardLayout>
Features:
- Sticky header for progress indicator
- Fixed bottom navigation on mobile, inline on desktop (
sm:breakpoint) - Auto padding (
pb-24on mobile,sm:pb-8on desktop) - Centered content with configurable max-width
WizardProgress - Step indicator with variants:
// Bar variant (default) - progress bar with step counter
<WizardProgress
currentStep={0} // 0-based index
totalSteps={5}
stepTitle="Overview" // Optional
variant="bar" // 'bar' (default) | 'dots' | 'icons'
/>
// Icons variant - circles with icons and connector lines
import { Building, FileText, CreditCard } from 'lucide-react';
<WizardProgress
variant="icons"
currentStep={1}
steps={[
{ id: 'address', label: 'Home Address', icon: Building },
{ id: 'tax', label: 'Tax Info', icon: FileText },
{ id: 'bank', label: 'Bank Details', icon: CreditCard },
]}
showLabels={true} // Optional, defaults to true (hidden on mobile)
/>
- Bar variant: Progress bar + "Step X of Y" + optional title
- Dots variant: Dot indicators for each step
- Icons variant: Circles with Lucide icons, connector lines between steps, checkmarks for completed steps
- i18n support via
common:stepOfkey (bar variant)
WizardNavigation - Bottom navigation buttons:
<WizardNavigation
isFirstStep={currentStep === 0}
isLastStep={isLastStep}
// Optional - renders skip button
nextDisabled={!canProceed}
isPending={isLoading}
submitLabel={<Trans i18nKey="..." />} // Optional custom label
/>
Features:
- Back button (icon, hidden on first step)
- Next/Submit button (h-12 touch targets)
- Optional skip button
- Loading spinner state
Complete Example:
'use client';
import { MobileWizardLayout } from '@kit/ui/mobile-wizard-layout';
import { WizardNavigation } from '@kit/ui/wizard-navigation';
import { WizardProgress } from '@kit/ui/wizard-progress';
function MyWizard() {
const [currentStep, setCurrentStep] = useState(0);
const steps = ['Overview', 'Details', 'Review'];
const isLastStep = currentStep === steps.length - 1;
return (
<MobileWizardLayout
contentWidth="lg"
header={
<WizardProgress
currentStep={currentStep}
totalSteps={steps.length}
stepTitle={steps[currentStep]}
/>
}
navigation={
<WizardNavigation
isFirstStep={currentStep === 0}
isLastStep={isLastStep}
=> setCurrentStep((s) => s - 1)}
? handleSubmit : () => setCurrentStep((s) => s + 1)}
nextDisabled={!canProceed}
isPending={isSubmitting}
/>
}
>
<div className="space-y-4">
{renderStep()}
</div>
</MobileWizardLayout>
);
}
Usage in Ballee:
- Feedback Form (
/home/(user)/feedback/[eventId]) - Contract Details Wizard (
/home/(user)/dancer/contract-details) - Profile Setup Wizard (
/home/(user)/dancer/profile/setup) - uses WizardProgress for mobile
Quick Reference: Common Responsive Classes
| Purpose | Mobile | Tablet (sm:) |
Desktop (lg:) |
|---|---|---|---|
| Hide/Show | hidden |
sm:block |
lg:hidden |
| Flex direction | flex-col |
sm:flex-row |
- |
| Grid columns | grid-cols-1 |
sm:grid-cols-2 |
lg:grid-cols-3 |
| Text size | text-sm |
sm:text-base |
lg:text-lg |
| Padding | p-4 |
sm:p-6 |
lg:p-8 |
| Gap | gap-2 |
sm:gap-4 |
lg:gap-6 |
Existing App Patterns to Follow
These patterns are already established in the codebase:
// Wizard pages (contract-details, legal-status)
<div className="mx-auto max-w-2xl">
// Profile/dashboard pages
<div className="mx-auto w-full max-w-4xl space-y-6">
// Form pages
<div className="mx-auto max-w-3xl">
// Full-width with responsive padding
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
Toast Usage
import { toast } from '@kit/ui/sonner';
// Success
toast.success('Item created');
// Error
toast.error('Failed to create item');
// With description
toast.success('Item created', { description: 'Redirecting...' });
CRUD Mutations Hook (CRITICAL)
Use the generic useCrudMutations factory for consistent CRUD operations. Located at apps/web/lib/hooks/use-crud-mutations.ts.
Basic Usage
import { useCrudMutations } from '@/lib/hooks/use-crud-mutations';
import { createAction, updateAction, deleteAction } from './server/actions';
function useMyEntityMutations(options?: { onSuccess?: () => void }) {
const mutations = useCrudMutations<Entity, CreateInput, UpdateInput>({
entityName: 'entity', // For toast messages
queryKeys: ['entities', 'admin-entities'], // Cache keys to invalidate
actions: {
create: createAction,
update: updateAction,
delete: deleteAction,
},
optimisticUpdates: true, // Optional: enable optimistic UI
normalizeData: (data) => ({ // Optional: transform data before sending
...data,
isActive: data.isActive ?? true,
}),
}, {
onSuccess: (data) => options?.onSuccess?.(data),
});
// Return with entity-specific naming for backwards compatibility
return {
createEntity: mutations.create,
createEntityAsync: mutations.createAsync,
isCreating: mutations.isCreating,
updateEntity: mutations.update,
updateEntityAsync: mutations.updateAsync,
isUpdating: mutations.isUpdating,
deleteEntity: mutations.delete,
deleteEntityAsync: mutations.deleteAsync,
isDeleting: mutations.isDeleting,
};
}
Update Call Signature
The update function uses { id, data } format:
// Using the hook
const { updateEntity } = useEntityMutations();
// ✅ CORRECT - wrap data in 'data' property
await updateEntity({ id: entity.id, data: { name: 'New Name' } });
// ❌ WRONG - flat object (old pattern)
await updateEntity({ id: entity.id, name: 'New Name' });
Migrated Hooks (Reference)
These admin hooks use useCrudMutations:
use-venue-mutations.tsuse-choreographer-mutations.tsuse-piece-mutations.tsuse-client-mutations.tsuse-jurisdiction-mutations.tsuse-engagement-model-mutations.tsuse-per-diem-rate-mutations.tsuse-legal-status-type-mutations.ts
When NOT to Use
Don't use useCrudMutations for specialized workflows:
- Accept/decline flows (use-assignment-mutations)
- Approve/reject flows (use-dancer-legal-status-mutations)
- Status change workflows (use-campaign-mutations)
- Link/unlink operations (use-client-user-mutations)
Dialog State Hook
Use useDialogState for consistent dialog state management. Located at apps/web/lib/hooks/use-dialog-state.ts.
Basic Dialog State
import { useDialogState } from '@/lib/hooks/use-dialog-state';
function MyComponent() {
const dialog = useDialogState();
return (
<>
<Button
<Dialog open={dialog.open}
<DialogContent>
<Button
</DialogContent>
</Dialog>
</>
);
}
Dialog with Selected Item (Edit Dialogs)
import { useDialogStateWithItem } from '@/lib/hooks/use-dialog-state';
function EditDialog() {
const { open, selectedItem, openWith, closeAndClear } = useDialogStateWithItem<Entity>();
return (
<>
<Button => openWith(entity)}>Edit</Button>
<Dialog open={open} => !isOpen && closeAndClear()}>
<DialogContent>
{selectedItem && <EditForm entity={selectedItem} />}
</DialogContent>
</Dialog>
</>
);
}
API Reference
// useDialogState()
interface DialogState {
open: boolean;
setOpen: (open: boolean) => void;
handleOpen: () => void;
handleClose: () => void;
toggle: () => void;
}
// useDialogStateWithItem<T>() - extends DialogState
interface DialogStateWithItem<T> extends DialogState {
selectedItem: T | null;
openWith: (item: T) => void; // Open dialog with specific item
closeAndClear: () => void; // Close and clear selection (with delay)
}
Centralized Formatters
Use formatters from apps/web/lib/formatters.ts for consistent formatting across the app.
Available Formatters
import {
formatCurrency,
formatNumber,
formatPercent,
formatDate,
formatTime,
formatTimeRange,
formatShowtimeDate,
formatFeverDate,
getFullName,
formatReimbursementType,
formatReimbursementsText,
} from '@/lib/formatters';
// Currency (EUR default)
formatCurrency(1234.56); // "€1,234.56"
formatCurrency(1234.56, 'USD'); // "$1,234.56"
// Numbers
formatNumber(1234.567); // "1,234.57"
formatNumber(1234.567, 0); // "1,235"
formatPercent(0.856); // "85.6%"
formatPercent(0.856, 0); // "86%"
// Dates
formatDate('2025-01-15'); // "Jan 15, 2025"
formatDate('2025-01-15', 'full'); // "Wednesday, January 15, 2025"
formatDate('2025-01-15', 'short'); // "1/15/25"
formatTime('14:30:00'); // "2:30 PM"
formatTimeRange('09:00', '17:00'); // "9:00 AM - 5:00 PM"
formatShowtimeDate('2025-01-15'); // "Wed Jan 15"
formatFeverDate('2025-01-15'); // "15.01.2025"
// Names
getFullName({ first_name: 'John', last_name: 'Doe' }); // "John Doe"
// Reimbursements
formatReimbursementType('transport'); // "Transport"
formatReimbursementsText([{ type: 'hotel', amount: 100 }]); // "Hotel: €100.00"
When to Use
- Admin pages: formatCurrency, formatDate, formatNumber
- Event displays: formatShowtimeDate, formatTimeRange
- Invoice/contract: formatFeverDate (DD.MM.YYYY format)
- Profile displays: getFullName
- Reimbursement lists: formatReimbursementsText
Data Mutation Pattern (CRITICAL)
Architecture Overview
Ballee uses a hybrid approach:
- Server Components (RSC) load initial page data
- TanStack Query provides optimistic UI updates
- Server Actions handle mutations and revalidate RSC cache
The key insight: Server Actions MUST call revalidatePath() to sync RSC data. Client-side queryClient.invalidateQueries() only affects client cache.
Complete Pattern
┌─────────────────────────────────────────────────────────────────┐
│ Client Component │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ useMutation hook │ │
│ │ - onMutate: optimistic update (instant UI) │ │
│ │ - mutationFn: calls server action │ │
│ │ - onSuccess: toast + optional client cache invalidation │ │
│ │ - onError: rollback + toast │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Server Action │
│ - Validate input (Zod) │
│ - Call service │
│ - revalidatePath() on success ← CRITICAL! │
│ - Return result │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ RSC Cache Invalidated → Page re-renders with fresh data │
└─────────────────────────────────────────────────────────────────┘
✅ CORRECT Pattern: Server Action + TanStack Query
Step 1: Server Action with revalidatePath
// apps/web/app/admin/items/_lib/server/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { withAuthParams } from '@/lib/auth-wrappers';
export const deleteItemAction = withAuthParams(async (params, id: string) => {
const service = new ItemService(params.client);
const result = await service.delete(id);
if (result.success) {
revalidatePath('/admin/items'); // ← CRITICAL: Invalidates RSC cache
}
return result;
});
Step 2: TanStack Query Hook for Optimistic UI
// apps/web/app/admin/items/_lib/hooks/use-item-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from '@kit/ui/sonner';
import { deleteItemAction } from '../server/actions';
export function useItemMutations() {
const queryClient = useQueryClient();
// NOTE: No useRouter needed - server action handles revalidation
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const result = await deleteItemAction(id);
if ('error' in result && result.error) {
throw new Error(result.error);
}
return result;
},
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['items'] });
// Snapshot for rollback
const previousItems = queryClient.getQueryData(['items']);
// Optimistic update - UI updates instantly
queryClient.setQueryData(['items'], (old: Item[] | undefined) =>
old?.filter((item) => item.id !== id)
);
return { previousItems };
},
onError: (error, _id, context) => {
// Rollback on error
if (context?.previousItems) {
queryClient.setQueryData(['items'], context.previousItems);
}
toast.error(error instanceof Error ? error.message : 'Failed to delete');
},
onSuccess: () => {
// Optional: Invalidate client cache for components using useQuery
queryClient.invalidateQueries({ queryKey: ['items'] });
toast.success('Item deleted');
// NO router.refresh() - server action already called revalidatePath()!
},
});
return {
deleteItem: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
};
}
❌ FORBIDDEN Patterns
// ❌ WRONG - router.refresh() in client hook
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
router.refresh(); // 700ms+ full page
…(truncated)