Component Patterns
Preferred approaches for common UI scenarios.
Overview
These patterns represent the preferred way to build common UI in Catalyst. Following them ensures consistency and makes it easier for AI agents to understand and modify the codebase.
Forms
Simple Forms
When: POC stage, 1-5 fields, no complex validation
Approach:
- • Use native form with onSubmit handler
- • Use Field, FieldLabel, Input components
- • Basic client-side validation with required
- • Show errors inline below fields
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" required />
</Field>
<Button type="submit">Save</Button>
</FieldGroup>
</form>Complex Forms
When: MVP+ stage, 5+ fields, validation rules, multi-step
Approach:
- • Use react-hook-form for state management
- • Add zod for schema validation
- • Handle errors via form.formState.errors
- • Consider multi-step pattern for long forms
// Install: npm install react-hook-form zod @hookform/resolvers
const form = useForm<FormData>({
resolver: zodResolver(schema),
})
// Then use form.register, form.handleSubmit, etc.Tables
Basic Table
When: Static data, < 50 rows, no sorting/filtering
Approach:
- • Use Table, TableHeader, TableBody, TableRow, TableCell
- • Map data directly in JSX
- • No external library needed
<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>Advanced Table
When: Sorting, filtering, pagination, large datasets
Approach:
- • Use @tanstack/react-table for state
- • Wrap in components/vendors/data-table.tsx
- • Support column visibility, sorting, filtering
// Install: npm install @tanstack/react-table // Create columns definition and use useReactTable hook // See components/vendors for wrapper pattern
Empty States
Empty State Pattern
When: No data to display, first-time user, search with no results
Approach:
- • Centered layout with icon
- • Clear message explaining the state
- • Action button to resolve (add item, clear filters)
- • Keep it simple — don't over-illustrate
<div className="flex flex-col items-center py-12 text-center">
<FileIcon className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 font-medium">No items yet</h3>
<p className="text-muted-foreground mt-1 text-sm">
Create your first item to get started.
</p>
<Button className="mt-4">Create Item</Button>
</div>Loading States
Skeleton Loading
When: Content shape is known, perceived performance matters
Approach:
- • Use Skeleton component matching content layout
- • Show skeleton immediately while data loads
- • Don't over-skeleton — focus on primary content
// Loading state
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
// Loaded state
<div className="space-y-4">
<h1>{data.title}</h1>
<p>{data.description}</p>
</div>Spinner Loading
When: Quick operations, buttons, unknown content shape
Approach:
- • Use spinner icon in buttons during submit
- • Disable button while loading
- • Show brief loading state, not skeletons
<Button disabled={isLoading}>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{isLoading ? "Saving..." : "Save"}
</Button>Dialogs
Confirmation Dialog
When: Destructive actions, irreversible operations
Approach:
- • Use AlertDialog for confirmations
- • Clear title stating the action
- • Explain consequences in description
- • Destructive action button styled appropriately
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete item?</AlertDialogTitle>
<AlertDialogDescription>
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>Form Dialog
When: Quick create/edit without leaving context
Approach:
- • Use Dialog for forms
- • Keep form simple (< 5 fields)
- • Close on successful submit
- • Show loading state on submit button
<Dialog>
<DialogTrigger asChild>
<Button>Add Item</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Item</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
{/* Form fields */}
<DialogFooter>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>Page Structure
App Page Pattern
When: Standard app page with header and content
Approach:
- • Page header with title and actions
- • Content area with appropriate max-width
- • Use consistent spacing (space-y-6 or space-y-8)
export default function Page() {
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Page Title</h1>
<p className="text-muted-foreground">Description</p>
</div>
<Button>Action</Button>
</div>
{/* Content */}
<div>{/* Main content here */}</div>
</div>
)
}List/Detail Pattern
When: Master-detail views, item selection
Approach:
- • List view with selectable items
- • Detail view in panel or separate route
- • Maintain selection state
- • Handle empty selection state
// Option 1: Side panel (Sheet) // Option 2: Separate route (/items/[id]) // Option 3: Split view (flex, list on left, detail on right)
Pattern Principle
Start simple. Use the basic pattern first. Only reach for the complex pattern when you have a clear need. Over-engineering in POC stage creates unnecessary work.
Related
See Examples for concrete implementations, and UI Components for the component library reference.