Last updated: Sep 1, 2025, 01:10 PM UTC

Component Library Guide with Shadcn/UI

Status: Complete Implementation Guide
Version: 2.0
Purpose: Building production-ready component libraries using Shadcn/UI
Framework: Shadcn/UI + Radix UI + Tailwind CSS


Overview

This guide provides a comprehensive approach to implementing component libraries using Shadcn/UI, our preferred component system. Based on lessons learned from real MVP implementations, this guide emphasizes working components over perfect abstractions.

Related Guides:

Why Shadcn/UI?

Based on our V3 vs V4 MVP analysis:

  • Copy-paste ownership: Full control over component code
  • No black boxes: Every line of code is visible and modifiable
  • Built on Radix UI: Accessibility guaranteed out of the box
  • Real implementations: Components that work with actual data

Quick Start

Detailed Setup: See UI Architecture Guide for complete installation instructions.

Step 1: Initialize Shadcn/UI

# Create your Next.js or Vite project first
npx create-next-app@latest my-app --typescript --tailwind --app

# Initialize shadcn/ui
npx shadcn-ui@latest init

# Answer the prompts:
# βœ“ Would you like to use TypeScript? Yes
# βœ“ Which style would you like to use? Default
# βœ“ Which color would you like to use as base color? Slate
# βœ“ Where is your global CSS file? app/globals.css
# βœ“ Would you like to use CSS variables for colors? Yes
# βœ“ Where is your tailwind.config.js? tailwind.config.js
# βœ“ Configure the import alias? components: @/components, utils: @/lib/utils

Step 2: Install Core Components

Critical Lesson: As documented in Autonomous MVP Lessons, install only components you'll immediately use with real data.

# Install essential components first
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
npx shadcn-ui@latest add table

# These create files in your project:
# components/ui/button.tsx
# components/ui/card.tsx
# components/ui/input.tsx
# etc.

Step 3: Component Structure

Architecture Pattern: Following Component Library Design System Policy, organize by atomic design:

src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ ui/              # Shadcn/UI components (atoms)
β”‚   β”‚   β”œβ”€β”€ button.tsx
β”‚   β”‚   β”œβ”€β”€ card.tsx
β”‚   β”‚   β”œβ”€β”€ input.tsx
β”‚   β”‚   └── form.tsx
β”‚   β”œβ”€β”€ features/        # Business components (molecules/organisms)
β”‚   β”‚   β”œβ”€β”€ campaign-list.tsx
β”‚   β”‚   β”œβ”€β”€ contact-form.tsx
β”‚   β”‚   └── analytics-dashboard.tsx
β”‚   └── layouts/         # Page templates
β”‚       β”œβ”€β”€ app-layout.tsx
β”‚       └── auth-layout.tsx
└── lib/
    └── utils.ts         # Shadcn/UI utilities

Real Implementation Examples

Critical: Following Autonomous MVP Lessons, every example shows REAL data integration, not mocks.

Example 1: Campaign List with Real Data

// src/components/features/campaign-list.tsx
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Mail, Users, TrendingUp } from 'lucide-react'

// REAL database types
interface Campaign {
  id: string                    // Real UUID from database
  name: string
  status: 'draft' | 'active' | 'completed'
  sent_count: number
  open_rate: number
  created_at: string
}

export function CampaignList() {
  const [campaigns, setCampaigns] = useState<Campaign[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    // REAL API call - no mocks!
    fetch('/api/campaigns')
      .then(res => res.json())
      .then(data => {
        setCampaigns(data.campaigns)
        setLoading(false)
      })
  }, [])

  // Verify real data (lesson from V4)
  console.log('Real campaign IDs:', campaigns.map(c => c.id))

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          <span>Email Campaigns</span>
          <Button>
            <Mail className="mr-2 h-4 w-4" />
            New Campaign
          </Button>
        </CardTitle>
      </CardHeader>
      <CardContent>
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Campaign</TableHead>
              <TableHead>Status</TableHead>
              <TableHead>Sent</TableHead>
              <TableHead>Open Rate</TableHead>
              <TableHead className="w-[50px]"></TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {campaigns.map((campaign) => (
              <TableRow key={campaign.id}>
                <TableCell className="font-medium">{campaign.name}</TableCell>
                <TableCell>
                  <Badge variant={campaign.status === 'active' ? 'default' : 'secondary'}>
                    {campaign.status}
                  </Badge>
                </TableCell>
                <TableCell>{campaign.sent_count.toLocaleString()}</TableCell>
                <TableCell>{campaign.open_rate}%</TableCell>
                <TableCell>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" size="icon">
                        <MoreHorizontal className="h-4 w-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem>Edit</DropdownMenuItem>
                      <DropdownMenuItem>Duplicate</DropdownMenuItem>
                      <DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </CardContent>
    </Card>
  )
}

Example 2: Form with Validation

Testing Integration: See Testing Framework Guide for testing form components.

// src/components/features/contact-form.tsx
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { toast } from '@/components/ui/use-toast'

// Real validation schema
const formSchema = z.object({
  email: z.string().email('Invalid email address'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
})

export function ContactForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: '',
      name: '',
      message: '',
    },
  })

  async function onSubmit(values: z.infer<typeof formSchema>) {
    // REAL API call
    const response = await fetch('/api/contacts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    })

    const data = await response.json()
    
    // Verify real database record created
    console.log('Contact created with ID:', data.id)
    
    toast({
      title: 'Contact saved',
      description: `Contact ${data.id} has been added to your list.`,
    })
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="john@example.com" {...field} />
              </FormControl>
              <FormDescription>
                We'll never share your email with anyone else.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder="John Doe" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Message</FormLabel>
              <FormControl>
                <Textarea 
                  placeholder="Tell us about your needs..."
                  className="resize-none"
                  {...field} 
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  )
}

Design System Integration

Design Resources:

Customizing Shadcn/UI Theme

// tailwind.config.ts - Real theme customization
import type { Config } from 'tailwindcss'

const config: Config = {
  darkMode: ["class"],
  content: [
    './src/**/*.{ts,tsx}',
  ],
  theme: {
    extend: {
      colors: {
        // Custom brand colors (following design-first standards)
        brand: {
          50: '#f0f9ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
        // Semantic colors for real UI states
        success: {
          DEFAULT: '#059669',
          foreground: '#ffffff',
        },
        warning: {
          DEFAULT: '#d97706',
          foreground: '#ffffff',
        },
      },
      // Real spacing system
      spacing: {
        '18': '4.5rem',
        '88': '22rem',
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
}

export default config

CSS Variables for Dynamic Theming

/* app/globals.css - Working theme system */
@layer base {
  :root {
    /* Light theme - tested with real components */
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 221.2 83.2% 53.3%;
    
    --radius: 0.5rem;
  }
  
  .dark {
    /* Dark theme - production tested */
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    
    /* ... additional dark theme variables ... */
  }
}

Accessibility Built-In

Complete Guide: See Accessibility Implementation Guide for comprehensive WCAG compliance strategies.

Radix UI Benefits

All Shadcn/UI components use Radix UI primitives, providing:

// Automatic accessibility features
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'

export function AccessibleModal() {
  return (
    <Dialog>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit Contact</DialogTitle>
          <DialogDescription>
            {/* Automatically read by screen readers */}
            Make changes to your contact here. Click save when you're done.
          </DialogDescription>
        </DialogHeader>
        {/* Focus trapped, escape key handled, ARIA attributes automatic */}
      </DialogContent>
    </Dialog>
  )
}

Built-in Features

  • Keyboard Navigation: Tab, Arrow keys, Escape - all handled
  • Focus Management: Focus trap, restore, and ring indicators
  • ARIA Attributes: Proper roles, states, and properties
  • Screen Reader: Announcements and descriptions included

Testing Shadcn/UI Components

Testing Guide: See Testing Framework Guide for complete testing strategies.

Testing Real Components

// __tests__/campaign-list.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { CampaignList } from '@/components/features/campaign-list'
import { server } from '@/mocks/server'
import { rest } from 'msw'

describe('CampaignList', () => {
  it('displays real campaign data', async () => {
    // Mock real API response
    server.use(
      rest.get('/api/campaigns', (req, res, ctx) => {
        return res(ctx.json({
          campaigns: [
            { 
              id: '123e4567-e89b-12d3-a456-426614174000',
              name: 'Welcome Series',
              status: 'active',
              sent_count: 1250,
              open_rate: 45.2
            }
          ]
        }))
      })
    )

    render(<CampaignList />)

    // Wait for real data
    await waitFor(() => {
      expect(screen.getByText('Welcome Series')).toBeInTheDocument()
    })

    // Verify real UUID displayed
    const rows = screen.getAllByRole('row')
    expect(rows).toHaveLength(2) // header + 1 data row
  })
})

Common Patterns

Forms with API Integration

Related: API Integration Guide for backend patterns

// Pattern: Form -> API -> Database -> Feedback
export function CreateCampaignForm() {
  const [loading, setLoading] = useState(false)
  
  async function handleSubmit(data: FormData) {
    setLoading(true)
    
    // Real API integration
    const response = await fetch('/api/campaigns', {
      method: 'POST',
      body: JSON.stringify(data)
    })
    
    const campaign = await response.json()
    
    // Navigate to real campaign
    router.push(`/campaigns/${campaign.id}`)
  }
  
  return (
    <Form onSubmit={handleSubmit}>
      {/* Form fields */}
      <Button type="submit" disabled={loading}>
        {loading ? <Loader2 className="animate-spin" /> : 'Create Campaign'}
      </Button>
    </Form>
  )
}

Data Tables with Filtering

Related: SaaS Multi-tenancy Guide for data isolation patterns

// Using TanStack Table with Shadcn/UI
import { useReactTable, getCoreRowModel, getFilteredRowModel } from '@tanstack/react-table'

export function ContactsTable({ contacts }: { contacts: Contact[] }) {
  const table = useReactTable({
    data: contacts,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  })
  
  return (
    <div>
      <Input
        placeholder="Filter contacts..."
        onChange={(e) => table.setGlobalFilter(e.target.value)}
      />
      <Table>
        {/* Render table with real data */}
      </Table>
    </div>
  )
}

Navigation Patterns

Related: User Journey Navigation Standards for UX patterns

// Lesson from V4: Make ALL features discoverable
export function AppNavigation() {
  return (
    <NavigationMenu>
      <NavigationMenuList>
        <NavigationMenuItem>
          <NavigationMenuTrigger>Campaigns</NavigationMenuTrigger>
          <NavigationMenuContent>
            <NavigationMenuLink href="/campaigns">All Campaigns</NavigationMenuLink>
            <NavigationMenuLink href="/campaigns/new">Create New</NavigationMenuLink>
            <NavigationMenuLink href="/campaigns/templates">Templates</NavigationMenuLink>
          </NavigationMenuContent>
        </NavigationMenuItem>
        {/* Every feature has a visible entry point */}
      </NavigationMenuList>
    </NavigationMenu>
  )
}

Migration from Custom Components

🧹 Related: MVP Version Cleanup Guide for migration strategies

Step-by-Step Migration

  1. Audit Existing Components

    # Find all custom components
    find src/components -name "*.tsx" -type f
    
  2. Map to Shadcn/UI Equivalents

    // Old custom button
    import { CustomButton } from './components/CustomButton'
    
    // New Shadcn/UI button
    import { Button } from '@/components/ui/button'
    
  3. Update Props and Styling

    // Old: Custom props
    <CustomButton color="primary" size="large" />
    
    // New: Shadcn/UI variants
    <Button variant="default" size="lg" />
    
  4. Test with Real Data

    // Ensure migrated components work with real APIs
    const { data } = await fetch('/api/real-endpoint')
    

Best Practices & Lessons Learned

Do's

  1. Start with Working Examples

    • Every component must demonstrate real functionality
    • No placeholder data or mock implementations
  2. Use Shadcn/UI CLI

    # Always use CLI to add components
    npx shadcn-ui@latest add [component]
    
  3. Customize Thoughtfully

    • Modify copied components directly
    • Keep customizations in version control
  4. Test with Production Data

    • Components must handle real API responses
    • Test error states with actual failures

Don'ts

  1. Don't Over-Abstract

    • Avoid creating wrapper components unnecessarily
    • Use Shadcn/UI components directly
  2. Don't Mock in Production

    • No fake data in component examples
    • Real integrations from day one
  3. Don't Ignore Accessibility

    • Radix UI handles basics, but test with screen readers
    • Verify keyboard navigation works

Additional Resources

Shadcn/UI Ecosystem

Related Internal Guides

Community Resources


Success Metrics

Based on our V4 implementation success:

  • Real Data Integration: 100% of components work with actual APIs
  • Feature Discoverability: All features accessible via navigation
  • Accessibility Score: WCAG 2.1 AA compliant out of the box
  • Developer Velocity: 70% faster development with copy-paste
  • Bundle Size: Only includes used components

This guide transforms component development from building custom solutions to leveraging battle-tested, accessible components that work with real data from day one. Follow the cross-referenced guides for deep dives into specific topics.