Last updated: Aug 4, 2025, 11:26 AM UTC

UI Architecture Guide with shadcn/ui

Status: Complete
Category: Development & Technical Guides
Tags: #ui-architecture #shadcn #component-library #tailwind #radix-ui

Overview

This guide provides a comprehensive approach to building modern, accessible, and performant user interfaces using shadcn/ui, a copy-paste component system built on Radix UI primitives and styled with Tailwind CSS. This architecture prioritizes developer experience, accessibility, and customization while maintaining consistency across the application.

Table of Contents

  1. Component Architecture Philosophy
  2. shadcn/ui Integration Strategy
  3. Design System Integration
  4. Component Implementation Patterns
  5. Accessibility Standards
  6. Performance Optimization
  7. Testing Strategies
  8. Common Implementation Patterns
  9. Best Practices
  10. Troubleshooting

Component Architecture Philosophy

Why shadcn/ui?

shadcn/ui represents a paradigm shift in component library architecture:

Copy-Paste vs. npm Package Approach

Traditional component libraries ship as npm packages with these limitations:

  • Black box components with limited customization
  • Bundle bloat from unused components
  • Version lock-in and breaking changes
  • Style conflicts with existing design systems

shadcn/ui's copy-paste approach provides:

  • Full ownership of component code
  • Complete customization freedom
  • No dependency management overhead
  • Version-independent updates
  • Tree-shakeable by default

Built on Solid Foundations

// Example: Button component structure
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

// Radix UI provides unstyled, accessible primitives
// Tailwind CSS provides utility-first styling
// class-variance-authority manages component variants

Architecture Principles

  1. Accessibility First: Every component built on ARIA-compliant Radix UI primitives
  2. Composability: Small, focused components that combine into complex interfaces
  3. Customization: Every aspect modifiable without fighting the framework
  4. Performance: Only ship what you use, no unused code
  5. Type Safety: Full TypeScript support with proper inference

shadcn/ui Integration Strategy

Initial Setup

1. Prerequisites

# Required dependencies
npm install -D tailwindcss postcss autoprefixer
npm install tailwindcss-animate class-variance-authority clsx tailwind-merge
npm install @radix-ui/react-slot

2. Tailwind Configuration

// tailwind.config.js
module.exports = {
  darkMode: ["class"],
  content: [
    './pages/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
    './src/**/*.{ts,tsx}',
  ],
  theme: {
    container: {
      center: true,
      padding: "2rem",
      screens: {
        "2xl": "1400px",
      },
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        // ... additional color tokens
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
      keyframes: {
        "accordion-down": {
          from: { height: 0 },
          to: { height: "var(--radix-accordion-content-height)" },
        },
        "accordion-up": {
          from: { height: "var(--radix-accordion-content-height)" },
          to: { height: 0 },
        },
      },
      animation: {
        "accordion-down": "accordion-down 0.2s ease-out",
        "accordion-up": "accordion-up 0.2s ease-out",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
}

3. CSS Variables Setup

/* 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%;
    /* ... dark mode tokens */
  }
}

Component Installation Workflow

Using the CLI

# Install shadcn/ui CLI
npx shadcn-ui@latest init

# Add components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add form
npx shadcn-ui@latest add data-table

Manual Installation

// components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    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 hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

Design System Integration

Design Token Mapping

Color System

// lib/design-tokens.ts
export const colors = {
  brand: {
    50: 'hsl(var(--brand-50))',
    100: 'hsl(var(--brand-100))',
    // ... complete scale
    900: 'hsl(var(--brand-900))',
  },
  semantic: {
    success: 'hsl(var(--success))',
    warning: 'hsl(var(--warning))',
    error: 'hsl(var(--error))',
    info: 'hsl(var(--info))',
  }
}

Typography Scale

// components/ui/typography.tsx
import { cva, type VariantProps } from "class-variance-authority"

export const typographyVariants = cva("", {
  variants: {
    variant: {
      h1: "text-4xl font-bold tracking-tight lg:text-5xl",
      h2: "text-3xl font-semibold tracking-tight",
      h3: "text-2xl font-semibold tracking-tight",
      h4: "text-xl font-semibold tracking-tight",
      body: "text-base leading-relaxed",
      small: "text-sm text-muted-foreground",
    },
  },
  defaultVariants: {
    variant: "body",
  },
})

Theming Strategy

// lib/themes.ts
export const themes = {
  default: {
    name: "Default",
    cssVars: {
      "--primary": "222.2 47.4% 11.2%",
      "--primary-foreground": "210 40% 98%",
      // ... other variables
    }
  },
  ocean: {
    name: "Ocean",
    cssVars: {
      "--primary": "200 80% 50%",
      "--primary-foreground": "200 20% 98%",
      // ... other variables
    }
  }
}

// Theme provider component
export function ThemeProvider({ theme, children }) {
  React.useEffect(() => {
    const root = document.documentElement
    Object.entries(themes[theme].cssVars).forEach(([key, value]) => {
      root.style.setProperty(key, value)
    })
  }, [theme])
  
  return children
}

Component Implementation Patterns

Atomic Design with shadcn/ui

Atoms (Base Components)

// Basic building blocks
<Button />
<Input />
<Label />
<Badge />

Molecules (Compound Components)

// Combining atoms into functional units
function SearchInput() {
  return (
    <div className="relative">
      <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
      <Input placeholder="Search..." className="pl-8" />
    </div>
  )
}

Organisms (Complex Components)

// Full-featured component compositions
function DataTableToolbar({ table }) {
  return (
    <div className="flex items-center justify-between">
      <div className="flex flex-1 items-center space-x-2">
        <Input
          placeholder="Filter emails..."
          value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn("email")?.setFilterValue(event.target.value)
          }
          className="h-8 w-[150px] lg:w-[250px]"
        />
        {table.getColumn("status") && (
          <DataTableFacetedFilter
            column={table.getColumn("status")}
            title="Status"
            options={statuses}
          />
        )}
      </div>
      <DataTableViewOptions table={table} />
    </div>
  )
}

Compound Component Pattern

// Parent component with context
const TabsContext = React.createContext<TabsContextValue | null>(null)

function Tabs({ children, defaultValue, onValueChange }) {
  const [value, setValue] = React.useState(defaultValue)
  
  return (
    <TabsContext.Provider value={{ value, setValue, onValueChange }}>
      <div className="tabs-container">{children}</div>
    </TabsContext.Provider>
  )
}

// Child components consuming context
function TabsList({ children }) {
  return <div className="tabs-list">{children}</div>
}

function TabsTrigger({ value, children }) {
  const context = useTabsContext()
  return (
    <button
      className={cn("tabs-trigger", {
        "tabs-trigger-active": context.value === value
      })}
      onClick={() => context.setValue(value)}
    >
      {children}
    </button>
  )
}

Server vs. Client Components

// Server Component (default in Next.js 13+)
// components/product-list.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

async function ProductList() {
  const products = await fetchProducts() // Server-side data fetching
  
  return (
    <div className="grid gap-4">
      {products.map((product) => (
        <Card key={product.id}>
          <CardHeader>
            <CardTitle>{product.name}</CardTitle>
          </CardHeader>
          <CardContent>
            <p>{product.description}</p>
            <ProductActions productId={product.id} /> {/* Client component */}
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

// Client Component for interactivity
// components/product-actions.tsx
"use client"

import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"

function ProductActions({ productId }) {
  const { toast } = useToast()
  
  const handleAddToCart = () => {
    // Client-side interaction
    addToCart(productId)
    toast({
      title: "Added to cart",
      description: "Product has been added to your cart.",
    })
  }
  
  return (
    <Button onClick={handleAddToCart}>
      Add to Cart
    </Button>
  )
}

Accessibility Standards

WCAG 2.1 AA Compliance

All shadcn/ui components are built on Radix UI primitives that provide:

Keyboard Navigation

// Automatic keyboard support
<NavigationMenu>
  <NavigationMenuList>
    <NavigationMenuItem>
      {/* Arrow keys, Tab, Enter, Escape all work automatically */}
      <NavigationMenuTrigger>Features</NavigationMenuTrigger>
      <NavigationMenuContent>
        {/* Focus management handled by Radix */}
      </NavigationMenuContent>
    </NavigationMenuItem>
  </NavigationMenuList>
</NavigationMenu>

ARIA Attributes

// Radix UI automatically adds appropriate ARIA attributes
<Dialog>
  <DialogTrigger asChild>
    <Button>Open Dialog</Button>
  </DialogTrigger>
  <DialogContent>
    {/* Automatically includes:
        - role="dialog"
        - aria-modal="true"
        - aria-labelledby
        - aria-describedby
        - Focus trap
        - Escape key handling
    */}
    <DialogHeader>
      <DialogTitle>Are you sure?</DialogTitle>
      <DialogDescription>
        This action cannot be undone.
      </DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>

Screen Reader Optimization

// Proper labeling and descriptions
<Form>
  <FormField
    control={form.control}
    name="email"
    render={({ field }) => (
      <FormItem>
        <FormLabel>Email</FormLabel>
        <FormControl>
          <Input
            placeholder="Enter your email"
            {...field}
            aria-describedby="email-description email-message"
          />
        </FormControl>
        <FormDescription id="email-description">
          We'll never share your email.
        </FormDescription>
        <FormMessage id="email-message" />
      </FormItem>
    )}
  />
</Form>

Focus Management

// Custom focus management when needed
function CommandPalette() {
  const inputRef = React.useRef<HTMLInputElement>(null)
  
  React.useEffect(() => {
    // Focus input when palette opens
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        inputRef.current?.focus()
      }
    }
    document.addEventListener("keydown", down)
    return () => document.removeEventListener("keydown", down)
  }, [])
  
  return (
    <CommandDialog>
      <CommandInput ref={inputRef} placeholder="Type a command..." />
      {/* ... */}
    </CommandDialog>
  )
}

Performance Optimization

Tree-shaking and Bundle Size

// Only import what you need
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"

// Not this:
// import * as UI from "@/components/ui"

Code Splitting Strategies

// Lazy load heavy components
const DataTable = dynamic(
  () => import("@/components/ui/data-table").then(mod => mod.DataTable),
  { 
    loading: () => <TableSkeleton />,
    ssr: false 
  }
)

// Route-based splitting in Next.js
const AdminDashboard = lazy(() => import("./admin-dashboard"))

CSS Optimization

// tailwind.config.js - Purge unused styles
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    // Be specific about paths to optimize build
  ],
  // Use JIT mode for optimal performance
  mode: 'jit',
}

Component Memoization

// Memoize expensive components
const ExpensiveChart = React.memo(({ data }) => {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Analytics</CardTitle>
      </CardHeader>
      <CardContent>
        <ResponsiveContainer width="100%" height={350}>
          <BarChart data={data}>
            {/* ... */}
          </BarChart>
        </ResponsiveContainer>
      </CardContent>
    </Card>
  )
}, (prevProps, nextProps) => {
  // Custom comparison function
  return prevProps.data === nextProps.data
})

Testing Strategies

Component Testing

// __tests__/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from '@/components/ui/button'

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByRole('button')).toHaveTextContent('Click me')
  })
  
  it('handles click events', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click</Button>)
    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
  
  it('applies variant styles', () => {
    render(<Button variant="destructive">Delete</Button>)
    expect(screen.getByRole('button')).toHaveClass('bg-destructive')
  })
})

Visual Regression Testing

// .storybook/main.js
module.exports = {
  stories: ['../components/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@chromatic-com/storybook'
  ],
}

// components/ui/button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './button'

const meta: Meta<typeof Button> = {
  title: 'UI/Button',
  component: Button,
  parameters: {
    layout: 'centered',
  },
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'],
    },
    size: {
      control: 'select',
      options: ['default', 'sm', 'lg', 'icon'],
    },
  },
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {
    children: 'Button',
  },
}

export const AllVariants: Story = {
  render: () => (
    <div className="flex gap-4">
      <Button>Default</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
    </div>
  ),
}

Accessibility Testing

// accessibility.test.tsx
import { axe, toHaveNoViolations } from 'jest-axe'
import { render } from '@testing-library/react'
import { Form } from '@/components/ui/form'

expect.extend(toHaveNoViolations)

describe('Accessibility', () => {
  it('form has no accessibility violations', async () => {
    const { container } = render(
      <Form>
        <FormField name="email" label="Email" />
        <Button type="submit">Submit</Button>
      </Form>
    )
    const results = await axe(container)
    expect(results).toHaveNoViolations()
  })
})

Common Implementation Patterns

Form Handling with react-hook-form

// app/settings/profile-form.tsx
"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 profileFormSchema = z.object({
  username: z
    .string()
    .min(2, { message: "Username must be at least 2 characters." })
    .max(30, { message: "Username must not be longer than 30 characters." }),
  email: z
    .string({ required_error: "Please select an email to display." })
    .email(),
  bio: z.string().max(160).min(4),
})

type ProfileFormValues = z.infer<typeof profileFormSchema>

export function ProfileForm() {
  const form = useForm<ProfileFormValues>({
    resolver: zodResolver(profileFormSchema),
    defaultValues: {
      username: "",
      email: "",
      bio: "",
    },
  })

  async function onSubmit(data: ProfileFormValues) {
    toast({
      title: "You submitted the following values:",
      description: (
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
        </pre>
      ),
    })
  }

  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="shadcn" {...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 placeholder="email@example.com" {...field} />
              </FormControl>
              <FormDescription>
                Your email address is private.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Update profile</Button>
      </form>
    </Form>
  )
}

Data Tables with TanStack Table

// components/data-table.tsx
"use client"

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table"

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  })

  return (
    <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}
                  data-state={row.getIsSelected() && "selected"}
                >
                  {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>
  )
}

Authentication Flows

// components/auth/login-form.tsx
"use client"

import { useState } from "react"
import { useRouter } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Loader2 } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useToast } from "@/components/ui/use-toast"

const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
})

export function LoginForm() {
  const router = useRouter()
  const { toast } = useToast()
  const [isLoading, setIsLoading] = useState(false)

  const form = useForm({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  })

  async function onSubmit(values: z.infer<typeof loginSchema>) {
    setIsLoading(true)
    
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      })

      if (!response.ok) {
        throw new Error("Login failed")
      }

      toast({
        title: "Success",
        description: "You have been logged in successfully.",
      })
      
      router.push("/dashboard")
    } catch (error) {
      toast({
        title: "Error",
        description: "Invalid email or password.",
        variant: "destructive",
      })
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Login</CardTitle>
        <CardDescription>
          Enter your email and password to login.
        </CardDescription>
      </CardHeader>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <CardContent>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="name@example.com"
                {...form.register("email")}
              />
              {form.formState.errors.email && (
                <p className="text-sm text-red-500">
                  {form.formState.errors.email.message}
                </p>
              )}
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="password">Password</Label>
              <Input
                id="password"
                type="password"
                {...form.register("password")}
              />
              {form.formState.errors.password && (
                <p className="text-sm text-red-500">
                  {form.formState.errors.password.message}
                </p>
              )}
            </div>
          </div>
        </CardContent>
        <CardFooter className="flex justify-between">
          <Button variant="ghost" type="button">
            Forgot password?
          </Button>
          <Button type="submit" disabled={isLoading}>
            {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
            Login
          </Button>
        </CardFooter>
      </form>
    </Card>
  )
}

Dashboard Layouts

// app/dashboard/layout.tsx
import { Sidebar } from "@/components/dashboard/sidebar"
import { Header } from "@/components/dashboard/header"

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <div className="flex flex-1 flex-col overflow-hidden">
        <Header />
        <main className="flex-1 overflow-y-auto bg-background p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

// components/dashboard/sidebar.tsx
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  Home,
  Users,
  Settings,
  BarChart,
  Mail,
} from "lucide-react"

const sidebarItems = [
  { icon: Home, label: "Dashboard", href: "/dashboard" },
  { icon: Users, label: "Customers", href: "/dashboard/customers" },
  { icon: Mail, label: "Campaigns", href: "/dashboard/campaigns" },
  { icon: BarChart, label: "Analytics", href: "/dashboard/analytics" },
  { icon: Settings, label: "Settings", href: "/dashboard/settings" },
]

export function Sidebar() {
  return (
    <div className="flex h-full w-64 flex-col border-r">
      <div className="flex h-14 items-center border-b px-6">
        <h2 className="text-lg font-semibold">NudgeCampaign</h2>
      </div>
      <ScrollArea className="flex-1 px-3 py-4">
        <nav className="space-y-2">
          {sidebarItems.map((item) => (
            <Button
              key={item.href}
              variant="ghost"
              className="w-full justify-start"
              asChild
            >
              <a href={item.href}>
                <item.icon className="mr-2 h-4 w-4" />
                {item.label}
              </a>
            </Button>
          ))}
        </nav>
      </ScrollArea>
    </div>
  )
}

Best Practices

Component Organization

components/
β”œβ”€β”€ ui/                    # shadcn/ui components
β”‚   β”œβ”€β”€ button.tsx
β”‚   β”œβ”€β”€ card.tsx
β”‚   └── ...
β”œβ”€β”€ forms/                 # Form components
β”‚   β”œβ”€β”€ login-form.tsx
β”‚   └── profile-form.tsx
β”œβ”€β”€ layouts/              # Layout components
β”‚   β”œβ”€β”€ dashboard-layout.tsx
β”‚   └── marketing-layout.tsx
└── features/             # Feature-specific components
    β”œβ”€β”€ campaigns/
    └── analytics/

Naming Conventions

// Component files: PascalCase
Button.tsx
DataTable.tsx

// Utility files: camelCase
utils.ts
useToast.ts

// Style files: kebab-case
globals.css
button-variants.css

// Component exports: Named exports for consistency
export { Button, buttonVariants }
export { Card, CardHeader, CardContent, CardFooter }

Performance Checklist

  • Use dynamic imports for heavy components
  • Implement virtual scrolling for long lists
  • Optimize images with next/image
  • Minimize client component usage
  • Use React.memo for expensive components
  • Implement proper loading states
  • Add error boundaries
  • Use Suspense for async components

Troubleshooting

Common Issues and Solutions

Issue: Tailwind styles not applying

# Solution 1: Clear cache
rm -rf .next
npm run dev

# Solution 2: Check content paths in tailwind.config.js
content: [
  "./app/**/*.{js,ts,jsx,tsx}",
  "./components/**/*.{js,ts,jsx,tsx}",
]

Issue: Type errors with component props

// Ensure proper type exports
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

// Use proper generic types
function MyComponent<T extends object>({ data }: { data: T }) {
  // ...
}

Issue: Dark mode not working

// Check ThemeProvider setup
<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
>
  {children}
</ThemeProvider>

// Ensure dark mode class is applied
<html className={theme === 'dark' ? 'dark' : ''}>

Performance Debugging

// Use React DevTools Profiler
import { Profiler } from 'react'

function onRenderCallback(id, phase, actualDuration) {
  console.log(`${id} (${phase}) took ${actualDuration}ms`)
}

<Profiler id="Navigation" onRender={onRenderCallback}>
  <Navigation />
</Profiler>

Conclusion

shadcn/ui provides a modern, flexible approach to building user interfaces that prioritizes developer experience, accessibility, and performance. By following this architecture guide, you can create consistent, maintainable, and accessible applications that scale with your needs.

Key Takeaways

  1. Own your components - Full control and customization
  2. Accessibility by default - Built on Radix UI primitives
  3. Performance focused - Only ship what you use
  4. Type-safe - Full TypeScript support
  5. Flexible theming - CSS variables for easy customization
  6. Modern patterns - Server components, compound components
  7. Testing friendly - Easy to test with standard tools

Resources