- Consolidated multi-line arguments into single lines where appropriate in `useAuth`. - Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`). - Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`). - Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
20 KiB
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
- When to Create vs Compose
- Component Templates
- Variant Patterns (CVA)
- Prop Design
- Testing Checklist
- 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:
- ✅ You're reusing the same composition 3+ times
- ✅ The pattern has complex business logic
- ✅ 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
// ✅ 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
// ❌ 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
// ✅ 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
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
classNameprop - Use
cn()utility for merging - Base classes first, overrides last
Template 2: Component with Variants (CVA)
Use case: Component needs multiple visual variants
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.HTMLAttributesfor standard HTML props - Spread
...propsto pass through additional attributes
Template 3: Composition Component
Use case: Wrap multiple shadcn/ui components with consistent structure
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
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
defaultValuefor initial uncontrolled value - Use
value+onChangefor 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
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
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
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:
// ✅ Descriptive, semantic names
interface UserCardProps {
user: User;
onEdit: () => void;
isLoading: boolean;
showAvatar?: boolean;
}
DON'T:
// ❌ 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
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):
interface ButtonProps {
variant: 'default' | 'destructive' | 'outline';
size: 'sm' | 'default' | 'lg';
}
Boolean flags:
interface CardProps {
isLoading?: boolean;
isDisabled?: boolean;
showBorder?: boolean;
}
Callback props:
interface FormProps {
onSubmit: (data: FormData) => void;
onCancel?: () => void;
onChange?: (field: string, value: any) => void;
}
Render props (advanced customization):
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.
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.
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.
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
- Reference: shadcn/ui source code
Related Documentation:
- Components - shadcn/ui component library
- AI Guidelines - Component templates for AI
- Forms - Form component patterns
- Accessibility - Accessibility requirements
Last Updated: November 2, 2025