# 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 │ │ │ │ │ └─NO──> Can you compose multiple shadcn/ui components? │ │ │ ├─YES─> Compose them inline first │ │ │ │ ... │ │ │ │ │ └─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 {title} {description}

{content}

``` **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 ( {title} {description}

{content}

); } // Used once... why did we create this? ; ``` **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 ( {title} {Icon && }
{value}
{change && (

{change > 0 ? '+' : ''}{change}% from last month

)}
); } // Now used in 5+ places ``` **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 (
{children}
); } // Usage Content; ``` **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, VariantProps { // Additional props here } export function MyComponent({ variant, size, className, ...props }: MyComponentProps) { return
; } // Usage Content ; ``` **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 ( {title} {icon}
{value}
{description &&

{description}

}
); } // Usage } />; ``` **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 ( ); } // Uncontrolled usage Auto-save; // Controlled usage const [enabled, setEnabled] = useState(false); Auto-save ; ``` **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
Alert content
; ``` --- ### 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 ; ``` --- ### 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 { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; renderEmpty?: () => React.ReactNode; } // Usage } renderEmpty={() => } />; ``` --- ## 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 ( {title} {Icon && }
{value}
{change !== undefined && (

= 0 ? 'text-green-600' : 'text-destructive')}> {change >= 0 ? '+' : ''} {change}% from last month

)}
); } // Usage
; ``` **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; } 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 ( {title} {description} ); } // Usage const [showDeleteDialog, setShowDeleteDialog] = useState(false); { 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 (

{title}

{description &&

{description}

}
{action &&
{action}
}
); } // Usage router.push('/users/new')}> Create User } />; ``` --- ## 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