Files
fast-next-template/frontend/docs/design-system/05-component-creation.md
Felipe Cardoso 30cbaf8ad5 Add documentation for component creation and design system structure
- **Component Creation Guide:** Document best practices for creating reusable, accessible components using CVA patterns. Includes guidance on when to compose vs create, decision trees, templates, prop design, testing checklists, and real-world examples.
- **Design System README:** Introduce an organized structure for the design system documentation with quick navigation, learning paths, and reference links to key topics. Includes paths for quick starts, layouts, components, forms, and AI setup.
2025-11-02 12:32:01 +01:00

875 lines
20 KiB
Markdown

# Component Creation Guide
**Learn when to create custom components vs composing existing ones**, and master the patterns for building reusable, accessible components with variants using CVA (class-variance-authority).
---
## Table of Contents
1. [When to Create vs Compose](#when-to-create-vs-compose)
2. [Component Templates](#component-templates)
3. [Variant Patterns (CVA)](#variant-patterns-cva)
4. [Prop Design](#prop-design)
5. [Testing Checklist](#testing-checklist)
6. [Real-World Examples](#real-world-examples)
---
## When to Create vs Compose
### The Golden Rule
**80% of the time, you should COMPOSE existing shadcn/ui components.**
Only create custom components when:
1. ✅ You're reusing the same composition 3+ times
2. ✅ The pattern has complex business logic
3. ✅ You need variants beyond what shadcn/ui provides
---
### Decision Tree
```
Do you need a UI element?
├─ Does shadcn/ui have this component?
│ │
│ ├─YES─> Use it directly
│ │ <Button>Action</Button>
│ │
│ └─NO──> Can you compose multiple shadcn/ui components?
│ │
│ ├─YES─> Compose them inline first
│ │ <Card>
│ │ <CardHeader>...</CardHeader>
│ │ </Card>
│ │
│ └─NO──> Are you using this composition 3+ times?
│ │
│ ├─NO──> Keep composing inline
│ │
│ └─YES─> Create a custom component
│ function MyComponent() { ... }
```
---
### ✅ GOOD: Compose First
```tsx
// ✅ CORRECT - Compose inline
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p>{content}</p>
</CardContent>
<CardFooter>
<Button onClick={onAction}>{actionLabel}</Button>
</CardFooter>
</Card>
```
**Why this is good:**
- Simple and direct
- Easy to customize per use case
- No abstraction overhead
- Clear what's happening
---
### ❌ BAD: Over-Abstracting Too Soon
```tsx
// ❌ WRONG - Premature abstraction
function ContentCard({ title, description, content, actionLabel, onAction }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p>{content}</p>
</CardContent>
<CardFooter>
<Button onClick={onAction}>{actionLabel}</Button>
</CardFooter>
</Card>
);
}
// Used once... why did we create this?
<ContentCard title="..." description="..." content="..." />
```
**Problems:**
- ❌ Created before knowing if pattern is reused
- ❌ Inflexible (what if we need 2 buttons?)
- ❌ Unclear what it renders (abstraction hides structure)
- ❌ Harder to customize
---
### ✅ GOOD: Extract After 3+ Uses
```tsx
// ✅ CORRECT - After seeing pattern used 3 times, extract
function DashboardMetricCard({
title,
value,
change,
icon: Icon,
}: DashboardMetricCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change && (
<p className="text-xs text-muted-foreground">
{change > 0 ? '+' : ''}{change}% from last month
</p>
)}
</CardContent>
</Card>
);
}
// Now used in 5+ places
<DashboardMetricCard title="Total Revenue" value="$45,231.89" change={20.1} />
<DashboardMetricCard title="Subscriptions" value="+2350" change={12.5} />
```
**Why this works:**
- ✅ Pattern validated (used 3+ times)
- ✅ Specific purpose (dashboard metrics)
- ✅ Consistent structure across uses
- ✅ Easy to update all instances
---
## Component Templates
### Template 1: Basic Custom Component
**Use case**: Simple component with optional className override
```tsx
import { cn } from '@/lib/utils';
interface MyComponentProps {
className?: string;
children: React.ReactNode;
}
export function MyComponent({ className, children }: MyComponentProps) {
return (
<div className={cn(
"base-classes-here", // Base styles
className // Allow overrides
)}>
{children}
</div>
);
}
// Usage
<MyComponent className="custom-overrides">
Content
</MyComponent>
```
**Key points:**
- Always accept `className` prop
- Use `cn()` utility for merging
- Base classes first, overrides last
---
### Template 2: Component with Variants (CVA)
**Use case**: Component needs multiple visual variants
```tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const componentVariants = cva(
// Base classes (always applied)
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-8 px-3 text-xs",
default: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
interface MyComponentProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof componentVariants> {
// Additional props here
}
export function MyComponent({
variant,
size,
className,
...props
}: MyComponentProps) {
return (
<div
className={cn(componentVariants({ variant, size, className }))}
{...props}
/>
);
}
// Usage
<MyComponent variant="outline" size="lg">Content</MyComponent>
```
**Key points:**
- Use CVA for complex variant logic
- Always provide `defaultVariants`
- Extend `React.HTMLAttributes` for standard HTML props
- Spread `...props` to pass through additional attributes
---
### Template 3: Composition Component
**Use case**: Wrap multiple shadcn/ui components with consistent structure
```tsx
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface StatCardProps {
title: string;
value: string | number;
description?: string;
icon?: React.ReactNode;
className?: string;
}
export function StatCard({
title,
value,
description,
icon,
className,
}: StatCardProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</CardContent>
</Card>
);
}
// Usage
<StatCard
title="Total Users"
value="1,234"
description="+12% from last month"
icon={<Users className="h-4 w-4 text-muted-foreground" />}
/>
```
**Key points:**
- Compose from shadcn/ui primitives
- Keep structure consistent
- Optional props with `?`
- Descriptive prop names
---
### Template 4: Controlled Component
**Use case**: Component manages state internally but can be controlled
```tsx
import { useState } from 'react';
interface ToggleProps {
value?: boolean;
onChange?: (value: boolean) => void;
defaultValue?: boolean;
children: React.ReactNode;
}
export function Toggle({
value: controlledValue,
onChange,
defaultValue = false,
children,
}: ToggleProps) {
// Uncontrolled state
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
// Use controlled value if provided, otherwise use internal state
const value = controlledValue ?? uncontrolledValue;
const handleChange = (newValue: boolean) => {
if (onChange) {
onChange(newValue);
} else {
setUncontrolledValue(newValue);
}
};
return (
<button onClick={() => handleChange(!value)}>
{value ? '✓' : '○'} {children}
</button>
);
}
// Uncontrolled usage
<Toggle defaultValue={false}>Auto-save</Toggle>
// Controlled usage
const [enabled, setEnabled] = useState(false);
<Toggle value={enabled} onChange={setEnabled}>Auto-save</Toggle>
```
**Key points:**
- Support both controlled and uncontrolled modes
- Use `defaultValue` for initial uncontrolled value
- Use `value` + `onChange` for controlled mode
- Fallback to internal state if not controlled
---
## Variant Patterns (CVA)
### What is CVA?
**class-variance-authority** (CVA) is a utility for creating component variants with Tailwind CSS.
**Why use CVA?**
- ✅ Type-safe variant props
- ✅ Compound variants (combinations)
- ✅ Default variants
- ✅ Clean, readable syntax
---
### Basic Variant Pattern
```tsx
import { cva } from 'class-variance-authority';
const alertVariants = cva(
// Base classes (always applied)
"relative w-full rounded-lg border p-4",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
// Usage
<div className={alertVariants({ variant: "destructive" })}>
Alert content
</div>
```
---
### Multiple Variants
```tsx
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-8 px-3 text-xs",
default: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
// Usage
<button className={buttonVariants({ variant: "outline", size: "lg" })}>
Large Outline Button
</button>
```
---
### Compound Variants
**Use case**: Different classes when specific variant combinations are used
```tsx
const buttonVariants = cva("base-classes", {
variants: {
variant: {
default: "bg-primary",
destructive: "bg-destructive",
},
size: {
sm: "h-8",
lg: "h-12",
},
},
// Compound variants: specific combinations
compoundVariants: [
{
variant: "destructive",
size: "lg",
class: "text-lg font-bold", // Applied when BOTH are true
},
],
defaultVariants: {
variant: "default",
size: "sm",
},
});
```
---
## Prop Design
### Prop Naming Conventions
**DO**:
```tsx
// ✅ Descriptive, semantic names
interface UserCardProps {
user: User;
onEdit: () => void;
isLoading: boolean;
showAvatar?: boolean;
}
```
**DON'T**:
```tsx
// ❌ Generic, unclear names
interface CardProps {
data: any;
onClick: () => void;
loading: boolean;
flag?: boolean;
}
```
---
### Required vs Optional Props
**Guidelines:**
- Required: Core functionality depends on it
- Optional: Nice-to-have, has sensible default
```tsx
interface AlertProps {
// Required: Core to component
children: React.ReactNode;
// Optional: Has default variant
variant?: 'default' | 'destructive';
// Optional: Component works without it
onClose?: () => void;
icon?: React.ReactNode;
// Optional: Standard override
className?: string;
}
export function Alert({
children,
variant = 'default', // Default for optional prop
onClose,
icon,
className,
}: AlertProps) {
// ...
}
```
---
### Prop Type Patterns
**Enum props** (limited options):
```tsx
interface ButtonProps {
variant: 'default' | 'destructive' | 'outline';
size: 'sm' | 'default' | 'lg';
}
```
**Boolean flags**:
```tsx
interface CardProps {
isLoading?: boolean;
isDisabled?: boolean;
showBorder?: boolean;
}
```
**Callback props**:
```tsx
interface FormProps {
onSubmit: (data: FormData) => void;
onCancel?: () => void;
onChange?: (field: string, value: any) => void;
}
```
**Render props** (advanced customization):
```tsx
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
}
// Usage
<List
items={users}
renderItem={(user, i) => <UserCard key={i} user={user} />}
renderEmpty={() => <EmptyState />}
/>
```
---
## Testing Checklist
Before shipping a custom component, verify:
### Visual Testing
- [ ] **Light mode** - Component looks correct
- [ ] **Dark mode** - Component looks correct (toggle theme)
- [ ] **All variants** - Test each variant works
- [ ] **Responsive** - Mobile, tablet, desktop sizes
- [ ] **Loading state** - Shows loading correctly (if applicable)
- [ ] **Error state** - Shows errors correctly (if applicable)
- [ ] **Empty state** - Handles no data gracefully
### Accessibility Testing
- [ ] **Keyboard navigation** - Can be focused and activated with Tab/Enter
- [ ] **Focus indicators** - Visible focus ring (`:focus-visible`)
- [ ] **Screen reader** - ARIA labels and roles present
- [ ] **Color contrast** - 4.5:1 for text, 3:1 for UI (use contrast checker)
- [ ] **Semantic HTML** - Using correct HTML elements (button, nav, etc.)
### Functional Testing
- [ ] **Props work** - All props apply correctly
- [ ] **className override** - Can override styles with className prop
- [ ] **Controlled/uncontrolled** - Both modes work (if applicable)
- [ ] **Event handlers** - onClick, onChange, etc. fire correctly
- [ ] **TypeScript** - No type errors, props autocomplete
### Code Quality
- [ ] **No console errors** - Check browser console
- [ ] **No warnings** - React warnings, a11y warnings
- [ ] **Performance** - No unnecessary re-renders
- [ ] **Documentation** - JSDoc comments for complex props
---
## Real-World Examples
### Example 1: Stat Card
**Problem**: Dashboard shows 8 metric cards with same structure.
**Solution**: Extract composition after 3rd use.
```tsx
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
interface StatCardProps {
title: string;
value: string | number;
change?: number;
icon?: LucideIcon;
className?: string;
}
export function StatCard({
title,
value,
change,
icon: Icon,
className,
}: StatCardProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change !== undefined && (
<p className={cn(
"text-xs",
change >= 0 ? "text-green-600" : "text-destructive"
)}>
{change >= 0 ? '+' : ''}{change}% from last month
</p>
)}
</CardContent>
</Card>
);
}
// Usage
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="Total Revenue" value="$45,231.89" change={20.1} icon={DollarSign} />
<StatCard title="Subscriptions" value="+2350" change={12.5} icon={Users} />
<StatCard title="Sales" value="+12,234" change={19} icon={CreditCard} />
<StatCard title="Active Now" value="+573" change={-2.1} icon={Activity} />
</div>
```
**Why this works:**
- Specific purpose (dashboard metrics)
- Reused 8+ times
- Consistent structure
- Easy to update all instances
---
### Example 2: Confirmation Dialog
**Problem**: Need to confirm delete actions throughout app.
**Solution**: Create reusable confirmation dialog wrapper.
```tsx
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'destructive',
onConfirm,
}: ConfirmDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const handleConfirm = async () => {
setIsLoading(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Usage
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title="Delete User"
description="Are you sure you want to delete this user? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
onConfirm={async () => {
await deleteUser(user.id);
toast.success('User deleted');
}}
/>
```
**Why this works:**
- Common pattern (confirmations)
- Handles loading states automatically
- Consistent UX across app
- Easy to use
---
### Example 3: Page Header
**Problem**: Every page has header with title, description, and optional action.
**Solution**: Extract page header component.
```tsx
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function PageHeader({
title,
description,
action,
className,
}: PageHeaderProps) {
return (
<div className={cn("flex items-center justify-between", className)}>
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}
// Usage
<PageHeader
title="Users"
description="Manage system users and permissions"
action={
<Button onClick={() => router.push('/users/new')}>
<Plus className="mr-2 h-4 w-4" />
Create User
</Button>
}
/>
```
---
## Summary: Component Creation Checklist
Before creating a custom component, ask:
- [ ] **Is it reused 3+ times?** If no, compose inline
- [ ] **Does shadcn/ui have this?** If yes, use it
- [ ] **Can I compose existing components?** If yes, do that first
- [ ] **Does it need variants?** Use CVA
- [ ] **Is className supported?** Always allow overrides
- [ ] **Is it accessible?** Test keyboard, screen reader, contrast
- [ ] **Is it documented?** Add JSDoc comments
- [ ] **Does it follow conventions?** Match shadcn/ui patterns
---
## Next Steps
- **Practice**: Refactor inline compositions into components after 3+ uses
- **Explore**: [Component showcase](/dev/components)
- **Reference**: [shadcn/ui source code](https://github.com/shadcn-ui/ui/tree/main/apps/www/registry)
---
**Related Documentation:**
- [Components](./02-components.md) - shadcn/ui component library
- [AI Guidelines](./08-ai-guidelines.md) - Component templates for AI
- [Forms](./06-forms.md) - Form component patterns
- [Accessibility](./07-accessibility.md) - Accessibility requirements
**Last Updated**: November 2, 2025