MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Shadcn/ui: The Component Library That Isn't

Explore shadcn/ui: copy-paste components, Radix UI primitives, Tailwind CSS styling.

shadcn/uiReactUITailwind

By MinhVo

Introduction

Shadcn/ui has redefined how developers think about component libraries. Unlike traditional libraries like Material UI or Chakra UI that you install as dependencies, shadcn/ui provides beautifully designed components that you copy directly into your project. This "component library that isn't" approach gives you full ownership and control over your code while leveraging Radix UI primitives for accessibility and Tailwind CSS for styling.

The result is a collection of composable, accessible, and customizable components that integrate seamlessly with modern React applications. With over 50,000 GitHub stars and adoption by major companies, shadcn/ui has become the standard for building polished UIs without the overhead of traditional component libraries.

Modern UI design

Understanding Shadcn/ui: Core Concepts

Shadcn/ui operates on a fundamentally different philosophy than traditional component libraries. Instead of importing from a node_modules package, you own the source code of every component you use.

The Copy-Paste Philosophy

Traditional component libraries create a dependency between your project and the library. When the library updates, you must update. When you need customization, you often fight against the library's opinions. Shadcn/ui eliminates this friction entirely.

When you add a component, the CLI copies the source code into your project's components/ui directory. You can modify any component without worrying about breaking future updates or maintaining compatibility with an external package.

Radix UI Primitives

Every shadcn/ui component is built on Radix UI, a headless component library that handles accessibility, keyboard navigation, and focus management. Radix provides unstyled, accessible primitives that shadcn/ui styles with Tailwind CSS.

This separation means you get:

  • Full accessibility: ARIA attributes, keyboard navigation, and screen reader support
  • Behavioral consistency: Focus management, portal rendering, and animation support
  • Style flexibility: Complete control over visual presentation through Tailwind

Tailwind CSS Integration

Shadcn/ui uses Tailwind CSS for all styling, leveraging CSS variables for theming. This approach enables:

  • Consistent design tokens: Colors, spacing, and typography defined in CSS variables
  • Dark mode support: Built-in dark mode through CSS variable switching
  • Responsive design: Tailwind's responsive utilities work seamlessly
  • Performance: No runtime CSS-in-JS overhead

Component architecture

Architecture and Design Patterns

Project Structure

├── components/
│   └── ui/
│       ├── button.tsx
│       ├── card.tsx
│       ├── dialog.tsx
│       ├── dropdown-menu.tsx
│       ├── input.tsx
│       ├── select.tsx
│       ├── table.tsx
│       └── ...
├── lib/
│   └── utils.ts          # cn() utility for className merging
├── styles/
│   └── globals.css       # CSS variables and base styles
└── tailwind.config.ts

The cn() Utility

Shadcn/ui relies on a simple utility for merging Tailwind classes:

// lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

This utility combines clsx (conditional classes) with tailwind-merge (intelligent class merging) to handle complex conditional styling scenarios.

Component Composition Pattern

Shadcn/ui follows the compound component pattern, where complex UI elements are composed from smaller, focused components:

// Instead of a monolithic component
<Card>
  <CardHeader>
    <CardTitle>Account Settings</CardTitle>
    <CardDescription>Manage your account preferences</CardDescription>
  </CardHeader>
  <CardContent>
    <form>
      <div className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="name">Name</Label>
          <Input id="name" placeholder="Your name" />
        </div>
      </div>
    </form>
  </CardContent>
  <CardFooter className="flex justify-between">
    <Button variant="outline">Cancel</Button>
    <Button>Save Changes</Button>
  </CardFooter>
</Card>

Theming with CSS Variables

/* styles/globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --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: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }
 
  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}

UI component showcase

Step-by-Step Implementation

Installation

# Initialize shadcn/ui in your project
npx shadcn@latest init
 
# This will:
# 1. Create components.json configuration
# 2. Set up tailwind.config.ts with CSS variables
# 3. Add globals.css with theme variables
# 4. Create lib/utils.ts with cn() utility

Adding Components

# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add label
npx shadcn@latest add select
npx shadcn@latest add table
npx shadcn@latest add toast
 
# Add multiple components at once
npx shadcn@latest add button card dialog form input label

Building a Complete Form

"use client"
 
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 { toast } from "@/components/ui/use-toast"
 
const formSchema = z.object({
  username: z.string().min(2, {
    message: "Username must be at least 2 characters.",
  }),
  email: z.string().email({
    message: "Please enter a valid email address.",
  }),
})
 
export function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
    },
  })
 
  function onSubmit(values: z.infer<typeof formSchema>) {
    toast({
      title: "Profile updated",
      description: `Username: ${values.username}, Email: ${values.email}`,
    })
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="Enter username" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="Enter email" {...field} />
              </FormControl>
              <FormDescription>
                We'll use this for account notifications.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Update Profile</Button>
      </form>
    </Form>
  )
}

Creating a Data Table

"use client"
 
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  getPaginationRowModel,
  getSortedRowModel,
  SortingState,
  getFilteredRowModel,
} from "@tanstack/react-table"
import { useState } from "react"
 
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowUpDown, MoreHorizontal } from "lucide-react"
 
type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}
 
export const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: "email",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        Email
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string
      return (
        <div className={`capitalize px-2 py-1 rounded-full text-xs font-medium ${
          status === "success" ? "bg-green-100 text-green-800" :
          status === "failed" ? "bg-red-100 text-red-800" :
          "bg-yellow-100 text-yellow-800"
        }`}>
          {status}
        </div>
      )
    },
  },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"))
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(amount)
      return <div className="text-right font-medium">{formatted}</div>
    },
  },
  {
    id: "actions",
    cell: ({ row }) => {
      const payment = row.original
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" className="h-8 w-8 p-0">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem
              onClick={() => navigator.clipboard.writeText(payment.id)}
            >
              Copy payment ID
            </DropdownMenuItem>
            <DropdownMenuItem>View customer</DropdownMenuItem>
            <DropdownMenuItem>View details</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    },
  },
]
 
interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}
 
export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([])
  const [globalFilter, setGlobalFilter] = useState("")
 
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    getSortedRowModel: getSortedRowModel(),
    onGlobalFilterChange: setGlobalFilter,
    getFilteredRowModel: getFilteredRowModel(),
    state: { sorting, globalFilter },
  })
 
  return (
    <div>
      <div className="flex items-center py-4">
        <Input
          placeholder="Filter emails..."
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
          className="max-w-sm"
        />
      </div>
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2 py-4">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  )
}

Real-World Use Cases and Case Studies

SaaS Dashboard

A B2B SaaS startup rebuilt their entire admin dashboard using shadcn/ui in two weeks. The team transitioned from Material UI, reducing bundle size by 40% while gaining more granular control over component styling.

Key wins:

  • Custom theme matching brand guidelines without fighting library opinions
  • Faster development with composable component patterns
  • Improved accessibility scores from Radix UI primitives
  • Reduced dependency conflicts in monorepo setup

E-Commerce Platform

An e-commerce company used shadcn/ui for their product catalog, checkout flow, and admin panel. The copy-paste approach allowed them to customize every component for their unique UX requirements.

Notable implementations:

  • Custom product variant selector using Select and Command components
  • Multi-step checkout wizard with Form and Card composition
  • Responsive product grid with DataTable and Card components
  • Toast notifications for cart updates and order confirmations

Internal Tools Company

A developer tools company standardized on shadcn/ui for all internal applications. The consistent design system across 15 internal apps reduced context switching for developers and improved overall UX quality.

Best Practices for Production

  1. Keep Components Updated: Periodically check for component updates using npx shadcn@latest diff. Manually apply improvements while preserving customizations.

  2. Create a Component Library Layer: Build project-specific components on top of shadcn/ui primitives. This creates a reusable layer while maintaining the copy-paste benefits.

  3. Use CSS Variables Consistently: Define all colors through CSS variables for consistent theming. Avoid hardcoded Tailwind colors that bypass the theme system.

  4. Extend, Don't Modify: When customizing, extend components with wrapper patterns rather than modifying the original source. This simplifies future updates.

  5. Leverage Variants: Use the cva (class-variance-authority) library for creating component variants. Shadcn/ui uses this pattern extensively for size and style variants.

  6. Document Customizations: Track which components you've modified and why. This documentation helps when reviewing updates or onboarding new developers.

  7. Use Consistent Import Patterns: Organize imports consistently across your project. Import from the UI directory for base components and from your components directory for business components.

  8. Test Accessibility: While Radix UI handles accessibility, custom styling can sometimes break visual accessibility. Test with screen readers and keyboard navigation.

  9. Optimize Bundle Size: Only add components you actually use. Each unused component adds to your bundle, even if it's tree-shaken from the final build.

  10. Contribute Back: If you create valuable component patterns, consider sharing them with the community. Shadcn/ui's ecosystem grows through community contributions.

Common Pitfalls and Solutions

PitfallImpactSolution
Hardcoded colors bypass themeInconsistent dark modeAlways use CSS variable-based colors
Overriding Radix styles incorrectlyBroken accessibilityUse data attributes for styling hooks
Missing peer dependenciesRuntime errorsInstall all required peer dependencies
Not updating tailwind configMissing utilitiesEnsure tailwind.config.ts includes component paths
Copying components manuallyVersion inconsistenciesUse the CLI for consistent component installation
Ignoring component updatesMissing bug fixesPeriodically check for component improvements
Over-customizing componentsMaintenance burdenExtend with wrappers instead of deep modifications

Performance Optimization

Lazy Loading Components

// Lazy load heavy components
import dynamic from 'next/dynamic'
 
const DataTable = dynamic(() => import('@/components/data-table').then(mod => mod.DataTable), {
  loading: () => <TableSkeleton />,
  ssr: false
})
 
const Chart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />
})

Optimizing Re-renders

// Memoize expensive components
import { memo } from 'react'
 
const ExpensiveCard = memo(function ExpensiveCard({ data }: { data: ComplexData }) {
  return (
    <Card>
      <CardContent>
        {/* Complex rendering */}
      </CardContent>
    </Card>
  )
})

Tree Shaking Configuration

// tailwind.config.ts
export default {
  content: [
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
  ],
  // Ensure unused styles are purged
}

Comparison with Alternatives

Featureshadcn/uiMaterial UIChakra UIRadix Themes
InstallationCopy-pastenpm packagenpm packagenpm package
OwnershipFull sourceDependencyDependencyDependency
StylingTailwind CSSEmotion/styledEmotion/styledCSS
AccessibilityRadix UIBuilt-inBuilt-inRadix UI
Bundle SizeMinimal (only used)LargeMediumMedium
CustomizationUnlimitedLimited by APIGoodGood
Learning CurveLowMediumMediumLow
ThemingCSS VariablesTheme objectTheme objectCSS Variables

Advanced Patterns and Techniques

Creating Custom Components

// components/ui/status-badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
 
const badgeVariants = cva(
  "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
        secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
        outline: "text-foreground",
        success: "border-transparent bg-green-100 text-green-800",
        warning: "border-transparent bg-yellow-100 text-yellow-800",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)
 
export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}
 
export function StatusBadge({ className, variant, ...props }: BadgeProps) {
  return (
    <div className={cn(badgeVariants({ variant }), className)} {...props} />
  )
}

Composable Command Menu

"use client"
 
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command"
import { useState, useEffect } from "react"
import { Calculator, Calendar, CreditCard, Settings, Smile, User } from "lucide-react"
 
export function CommandMenu() {
  const [open, setOpen] = useState(false)
 
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        setOpen((open) => !open)
      }
    }
    document.addEventListener("keydown", down)
    return () => document.removeEventListener("keydown", down)
  }, [])
 
  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>
        <CommandGroup heading="Suggestions">
          <CommandItem>
            <Calendar className="mr-2 h-4 w-4" />
            <span>Calendar</span>
          </CommandItem>
          <CommandItem>
            <Smile className="mr-2 h-4 w-4" />
            <span>Search Emoji</span>
          </CommandItem>
          <CommandItem>
            <Calculator className="mr-2 h-4 w-4" />
            <span>Calculator</span>
          </CommandItem>
        </CommandGroup>
        <CommandSeparator />
        <CommandGroup heading="Settings">
          <CommandItem>
            <User className="mr-2 h-4 w-4" />
            <span>Profile</span>
          </CommandItem>
          <CommandItem>
            <CreditCard className="mr-2 h-4 w-4" />
            <span>Billing</span>
          </CommandItem>
          <CommandItem>
            <Settings className="mr-2 h-4 w-4" />
            <span>Settings</span>
          </CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  )
}

Testing Strategies

// __tests__/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '@/components/ui/button'
 
describe('Button', () => {
  it('renders with default variant', () => {
    render(<Button>Click me</Button>)
    const button = screen.getByRole('button', { name: 'Click me' })
    expect(button).toBeInTheDocument()
    expect(button).toHaveClass('bg-primary')
  })
 
  it('renders with destructive variant', () => {
    render(<Button variant="destructive">Delete</Button>)
    const button = screen.getByRole('button', { name: 'Delete' })
    expect(button).toHaveClass('bg-destructive')
  })
 
  it('handles click events', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)
    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
 
  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
  })
 
  it('renders with icon', () => {
    render(
      <Button>
        <svg data-testid="icon" />
        With Icon
      </Button>
    )
    expect(screen.getByTestId('icon')).toBeInTheDocument()
  })
})

Future Outlook

Shadcn/ui continues to evolve rapidly:

Figma Integration: Official Figma components for design-to-code workflows, enabling designers and developers to use the same component library.

Vue and Svelte Support: Community ports bringing shadcn/ui's design philosophy to other frameworks.

Enhanced Theming: More sophisticated theming capabilities with multi-brand support and dynamic theme generation.

Component Marketplace: Community-contributed components extending the base library with specialized UI patterns.

Conclusion

Shadcn/ui represents a paradigm shift in how we build React UIs. By copying components directly into your project, you gain full ownership and control while leveraging the best accessibility and styling foundations available.

Key takeaways:

  1. Copy-paste approach eliminates dependency management headaches
  2. Radix UI primitives ensure accessibility without custom implementation
  3. Tailwind CSS provides consistent, performant styling
  4. CSS variables enable flexible theming with dark mode support
  5. Composable patterns create maintainable, testable components

Start with the CLI initialization, add components as needed, and build your project-specific component library on top. The combination of ownership, accessibility, and styling flexibility makes shadcn/ui the optimal choice for modern React applications.