UI Component Patterns
Description
Guidelines and canonical patterns for building UI in the Roomote web application. Use when creating new pages, components, dialogs, forms, or modifying existing UI in apps/web/.
Trigger Conditions
- Creating or modifying React components in
apps/web/src/ - Building new pages or features with UI
- Working on settings pages, dialogs, forms, or card-based layouts
- Adding loading states, empty states, or error states
Import Conventions
All UI components and icons are imported from a single barrel:
// UI Components
import {
Button,
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
Badge,
Skeleton,
Form, FormField, FormItem, FormLabel, FormControl,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Switch,
Input,
Label,
Alert, AlertDescription,
} from '@/components/system';
// Icons (re-exported from lucide-react via barrel)
import { Settings, Users, ArrowRight, Loader2, Check } from '@/components/system';
// Settings components
import { Section } from '@/components/settings';
// Layout components
import { PageContainer, PageTitle } from '@/components/layout';
// State components
import { EmptyState, ErrorState } from '@/components/system';
Never import directly from lucide-react — always use the barrel.
Page Structure
Pages follow a consistent two-layer pattern:
// page.tsx — thin wrapper, validates params, renders main component
'use client';
import { PageContainer, PageTitle } from '@/components/layout';
import { MyFeature } from './MyFeature';
export default function Page() {
return (
<PageContainer>
<div className="space-y-3">
<PageTitle title="My Feature" />
<MyFeature />
</div>
</PageContainer>
);
}
// MyFeature.tsx — main component, owns data fetching and state
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
// ... component implementation
PageContainer provides gap-6 p-6 base spacing. Use wide={true} for full-width pages (settings, usage analytics).
Card Composition
Cards MUST always use subcomponents. Never dump raw content with padding overrides.
Analytics Summary Cards
Analytics overview metrics in apps/web/src/app/(authenticated)/analytics/
must use the shared AnalyticsSummaryCardsGrid and AnalyticsSummaryCard
components. Build new analytics summary rows like the PR analytics cards:
tight gap-0.5 grid, unbordered bg-card p-4 metric blocks, muted
medium-weight labels, large semibold values, and a muted secondary line. The
secondary/footer line should carry concrete context for the metric whenever
possible, such as the selected time period or the denominator behind an average.
Do not wrap the row in an extra padded card or add bordered inner tiles for
sibling analytics views.
Full card (with header, content, footer)
<Card>
<CardHeader>
<CardTitle>Review Changes</CardTitle>
<CardDescription>Review the proposed changes before merging.</CardDescription>
</CardHeader>
<CardContent>
{/* CardContent defaults to text-sm space-y-4 */}
<p>The following files will be modified:</p>
<ul>...</ul>
</CardContent>
<CardFooter align="end">
<Button variant="outline">Cancel</Button>
<Button>Merge</Button>
</CardFooter>
</Card>
CardFooter alignment
Use the align prop instead of className:
<CardFooter align="between"> {/* not className="justify-between" */}
<CardFooter align="end"> {/* not className="justify-end" */}
<CardFooter align="center"> {/* not className="justify-center" */}
Card with bordered header
<Card>
<CardHeader className="border-b">
<CardTitle>Section Title</CardTitle>
</CardHeader>
<CardContent>...</CardContent>
</Card>
Dialog Composition
Sizing
Use the size prop on DialogContent:
<Dialog open={open}
<DialogContent size="lg"> {/* sm | md | lg | xl | 2xl | max */}
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
<DialogDescription>Update the project configuration.</DialogDescription>
</DialogHeader>
{/* content */}
<DialogFooter>
<Button variant="outline" => onOpenChange(false)}>Cancel</Button>
<Button
</DialogFooter>
</DialogContent>
</Dialog>
Never override width via className. Use size prop.
Dialogs are scrollable by default — no need to add max-h-[90vh] overflow-y-auto.
Dialog state management
Dialogs are always controlled via open + onOpenChange props:
const [isOpen, setIsOpen] = useState(false);
<Dialog open={isOpen}
<DialogContent size="md">...</DialogContent>
</Dialog>
Settings Page Pattern
Section component
All settings cards use the Section component from @/components/settings:
import { Section } from '@/components/settings';
import { RefreshCw } from '@/components/system';
<Section icon={RefreshCw} title="Task Sync">
<div className="space-y-6">
<FormField
control={control}
name="enableTaskSync"
render={({ field }) => (
<FormControl>
<Switch checked={field.value} />
</FormControl>
)}
/>
<p>Save all extension tasks to Roomote.</p>
</div>
</Section>
Section with action slot (for toggle switches)
<Section
icon={Bell}
title="Push Notifications"
action={<Switch checked={enabled} />}
>
<p>Receive notifications when tasks complete or need attention.</p>
</Section>
Settings page form architecture
Settings pages use a shared form with child sections accessing via useFormContext:
// Parent: owns the form
const form = useForm<UpdateSettings>({
resolver: zodResolver(updateSettingsSchema),
defaultValues: getFormValues(data),
});
<Form {...form}>
<ChildSectionA />
<ChildSectionB />
</Form>
// Child: accesses form via context
const { control, watch, formState: { isSubmitting } } = useFormContext<UpdateSettings>();
Internal-Only UI
When adding internal-only product UI in apps/web/:
- Default it behind the user-level
Show Debug UIsetting. - Prefer Tailwind
debug:utilities when that is enough to keep the normal UI clean. - Reach for heavier gating only when the surface cannot be handled cleanly with
debug:alone.
Form Pattern
Select focus handoff
Use the shared Select's handoffTargetOnSelect prop only when committing a
choice clearly means the user's next action is to edit one specific text field
or choose from one specific dependent Select:
const detailsRef = useRef<HTMLInputElement>(null);
<Select handoffTargetOnSelect={detailsRef}>
{/* trigger, content, and items */}
</Select>
<Input ref={detailsRef} />
For a dependent shared Select, expose its handoff handle explicitly:
const channelSelectRef = useRef<SelectHandoffTarget>(null);
<Select handoffTargetOnSelect={channelSelectRef}>{/* provider */}</Select>
<Select handoffRef={channelSelectRef}>{/* channel */}</Select>
- Always pass an explicit
Input/Textarearef or shared Select handoff ref. Never infer the next control from DOM order. - Opt in for a destination revealed by the choice or an explicit "enter manually" choice. Do not opt in merely because an optional field is nearby.
- The handoff happens only after an item is committed and the dropdown closes. Browsing, Escape, outside dismissal, and cancelled item events retain normal Radix focus behavior.
- A text destination must be mounted, visible, enabled, editable, and textual when the source dropdown finishes closing. A Select destination must have a visible enabled trigger; it is focused and opened through its normal controlled or uncontrolled state path. Otherwise focus returns to the source trigger.
- Do not open a dependent Select when it is loading, has no usable choices, or already holds a valid choice. If options load asynchronously, retain a pending handoff only while focus remains on the source trigger so later user actions are never interrupted.
- Account for assistive technology and mobile keyboards. The focus move should preserve a logical reading order and opening the software keyboard should be the expected next step, not a surprise.
- Do not override
SelectContent.onCloseAutoFocusto recreate this behavior. The shared API coordinates with Radix focus restoration and respects a close handler that deliberately takes focus ownership.
See the current candidate audit for the approved adoptions and intentionally skipped flows.
Standard form with validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
Button, Input,
} from '@/components/system';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email(),
});
type FormValues = z.infer<typeof schema>;
export function MyForm() {
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
});
const (values: FormValues) => {
const result = await createThing(values);
if (result.success) {
toast.success('Created successfully');
} else {
toast.error(result.error);
}
};
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 ? <Loader2 className="animate-spin" /> : 'Submit'}
</Button>
</form>
</Form>
);
}
Form inside a dialog
Forms inside dialogs have their own useForm (they don't share with parent forms):
<Dialog open={open}
<DialogContent size="md">
<DialogHeader>
<DialogTitle>Create Item</DialogTitle>
</DialogHeader>
<Form {...form}>
<form className="space-y-4">
{/* form fields */}
<DialogFooter>
<Button variant="outline" => onOpenChange(false)}>Cancel</Button>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
Loading States
Structural skeletons (the standard)
Loading states must mirror the final content layout using Skeleton:
// Loading state for a card with header + content + footer
if (isPending) {
return (
<Card>
<CardHeader>
<CardTitle><Skeleton className="h-6 w-48" /></CardTitle>
<CardDescription><Skeleton className="h-4 w-72" /></CardDescription>
</CardHeader>
<CardContent>
<Skeleton className="h-32" />
</CardContent>
<CardFooter align="end">
<Skeleton className="h-9 w-24" />
</CardFooter>
</Card>
);
}
// Loading state for a list
if (isPending) {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
Error and empty states
import { EmptyState, ErrorState } from '@/components/system';
if (isError) {
return <ErrorState title="Failed to load tasks" />;
}
if (data.length === 0) {
return (
<EmptyState
icon={<CircleOff className="size-6 text-muted-foreground/50" />}
title="No tasks found"
description="Create your first task to get started."
/>
);
}
Standard data loading pattern
const { data, isPending, isError } = useQuery(...);
if (isPending) return <StructuralSkeleton />;
if (isError) return <ErrorState title="Failed to load" />;
if (data.length === 0) return <EmptyState title="Nothing here yet" />;
return <ActualContent data={data} />;
Never use LoadingState, GhostLoader, or inline spinners for in-page content loading. The Loading component from @/components/layout is reserved for full-page initial loads only.
Icon Conventions
Size scale
| Size | Context | Example |
|---|---|---|
size-3 |
Micro: inside xs/sm buttons, badges, status indicators | <Check className="size-3" /> |
size-4 |
Standard: buttons, card titles, settings, inline actions | <Settings className="size-4" /> |
size-5 |
Navigation: sidebar nav, toolbar buttons | <Home className="size-5" /> |
size-6 |
Hero: page headers, empty/error state icons | <Star className="size-6" /> |
Auto-sizing
Button and Badge auto-size their child icons:
- Button: icons default to
size-4— don't set explicit sizes - Badge: icons default to
size-3— don't set explicit sizes
// ✅ Correct — Button auto-sizes the icon
<Button><Settings /> Save Settings</Button>
// ❌ Wrong — unnecessary explicit size inside Button
<Button><Settings className="size-4" /> Save Settings</Button>
strokeWidth
Default strokeWidth is 1.5 (set via CSS). Never specify strokeWidth={1.5} explicitly. Only set strokeWidth when you need a different value (e.g., strokeWidth={1} for hero icons).
Badge Variants
// Semantic status badges
<Badge variant="success">Active</Badge> {/* green */}
<Badge variant="warning">Trial</Badge> {/* yellow */}
<Badge variant="destructive">Failed</Badge> {/* red */}
// Standard variants
<Badge variant="default">Cloud</Badge> {/* primary color */}
<Badge variant="secondary">v2.1</Badge> {/* muted */}
<Badge variant="outline">Draft</Badge> {/* border only */}
Never use className to apply status colors to Badge — use the variant prop.
Spacing Scale
| Value | Pixels | Semantic Use |
|---|---|---|
space-y-1 |
4px | Tight pairs: title + subtitle, label + description |
space-y-2 |
8px | Lists, small stacks, form field groups |
space-y-3 |
12px | Section content (inside Section.tsx) |
space-y-4 |
16px | Card content (CardContent default), form sections |
space-y-6 |
24px | Major sections: between cards, page-level sections |
PageContainer provides gap-6 p-6 as the page baseline.
Toast Notifications
Always use sonner:
import { toast } from 'sonner';
// Success
toast.success('Settings saved');
// Error
toast.error('Failed to save settings');
toast.error(error.message);
Pattern for mutations:
const mutation = useMutation({
onSuccess: (data) => {
if (data.success) {
toast.success('Updated successfully');
} else {
toast.error(data.error);
}
},
onError: (error) => toast.error(error.message),
});
Tooltip Pattern
BasicTooltip — simple cases
For elements that just need a hover tooltip with text:
import { BasicTooltip } from '@/components/system';
<BasicTooltip content="Delete this item">
<Button variant="ghost" size="icon">
<Trash2 />
</Button>
</BasicTooltip>
// With positioning
<BasicTooltip content="Settings" side="right">
<Button variant="ghost"><Settings /></Button>
</BasicTooltip>
Full Tooltip API — complex cases
For rich tooltip content or custom triggers, use the composable API:
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/system';
<Tooltip>
<TooltipTrigger asChild>
<Button>Hover me</Button>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<p className="font-semibold">Rich content</p>
<p className="text-xs">With multiple lines</p>
</div>
</TooltipContent>
</Tooltip>
Never wrap in <TooltipProvider> — Tooltip already includes one.
Anti-Patterns
Do NOT:
- Import icons directly from
lucide-react— use the barrel from@/components/system - Override Dialog width via className — use the
sizeprop - Override CardFooter alignment via className — use the
alignprop - Override Badge colors via className for status — use
variant="success"/variant="warning" - Use
LoadingStateorGhostLoader— use structural Skeleton components - Put raw content in Card without using CardContent subcomponent
- Specify
strokeWidth={1.5}on icons — it's the CSS default - Create ad-hoc settings cards with
<Card className="gap-2">— useSectionfrom@/components/settings - Override
text-smorspace-y-4on CardContent — these are now defaults
DO:
- Import everything from
@/components/systembarrel - Use Section.tsx for all settings cards
- Use structural Skeletons that mirror final content layout
- Use the component's built-in variants/props before reaching for className
- Follow the spacing scale: 1 → 2 → 3 → 4 → 6