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

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

  1. When to Create vs Compose
  2. Component Templates
  3. Variant Patterns (CVA)
  4. Prop Design
  5. Testing Checklist
  6. 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

// ✅ 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 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

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

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 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

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


Related Documentation:

Last Updated: November 2, 2025