shadcn/ui — Complete Setup, Customization, and Component Guide

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

shadcn/ui solves the customization problem with component libraries. Traditional libraries (MUI, Chakra) own the components — you fight their styles. shadcn/ui copies the component source code into your project. You own it completely and customize it without fighting !important overrides.

Installation and Setup

npx shadcn@latest init

The CLI asks about your framework, TypeScript usage, and styling preferences. It creates:

  • components/ui/ — component files you own
  • lib/utils.ts — the cn() utility
  • Updates globals.css with CSS variable tokens

Adding Components

Install individual components as needed:

npx shadcn@latest add button
npx shadcn@latest add input label
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add form  # Includes react-hook-form integration
npx shadcn@latest add table
npx shadcn@latest add toast

Each command copies the component source into components/ui/.

Form with Validation

The form component integrates React Hook Form and Zod:

'use client'
 
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { toast } from 'sonner'
 
const signUpSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string()
}).refine(d => d.password === d.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
})
 
type SignUpValues = z.infer<typeof signUpSchema>
 
export function SignUpForm() {
  const form = useForm<SignUpValues>({
    resolver: zodResolver(signUpSchema),
    defaultValues: { email: '', password: '', confirmPassword: '' }
  })
 
  async function onSubmit(values: SignUpValues) {
    const res = await fetch('/api/auth/signup', {
      method: 'POST',
      body: JSON.stringify(values)
    })
 
    if (res.ok) {
      toast.success('Account created! Please check your email.')
      form.reset()
    } else {
      const { error } = await res.json()
      toast.error(error ?? 'Sign up failed')
    }
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 max-w-md">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="confirmPassword"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Confirm Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? 'Creating account...' : 'Sign Up'}
        </Button>
      </form>
    </Form>
  )
}

Data Table with Sorting and Filtering

'use client'
 
import { useState } from 'react'
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
 
type User = { id: string; name: string; email: string; role: string; status: 'active' | 'inactive' }
 
export function UserTable({ users }: { users: User[] }) {
  const [filter, setFilter] = useState('')
 
  const filtered = users.filter(u =>
    u.name.toLowerCase().includes(filter.toLowerCase()) ||
    u.email.toLowerCase().includes(filter.toLowerCase())
  )
 
  return (
    <div className="space-y-4">
      <Input
        placeholder="Filter by name or email..."
        value={filter}
        onChange={e => setFilter(e.target.value)}
        className="max-w-sm"
      />
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Name</TableHead>
              <TableHead>Email</TableHead>
              <TableHead>Role</TableHead>
              <TableHead>Status</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {filtered.map(user => (
              <TableRow key={user.id}>
                <TableCell className="font-medium">{user.name}</TableCell>
                <TableCell className="text-gray-500">{user.email}</TableCell>
                <TableCell>{user.role}</TableCell>
                <TableCell>
                  <Badge variant={user.status === 'active' ? 'default' : 'secondary'}>
                    {user.status}
                  </Badge>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  )
}

Customizing the Design System

Edit the CSS variables in globals.css to change the entire design system:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;      /* Change to your brand color */
    --primary-foreground: 210 40% 98%;
    --radius: 0.5rem;                   /* Adjust for more/less rounded corners */
  }
  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
  }
}

Common Mistakes

  • Importing directly from @/components/ui/button then expecting upstream updates — you own the code, there are no updates
  • Not customizing CSS variables — the default blue/gray palette does not match most brands
  • Using shadcn/ui without react-hook-form for forms — the Form component is built around it
  • Forgetting to add <Toaster /> from sonner to your layout when using toast()

Best Practices

  • Customize the CSS variables in globals.css before building any components to establish brand consistency
  • Create wrapper components around shadcn components to add project-specific defaults
  • Use shadcn's CLI to update individual components when upstream improvements are available

Key Takeaways

  • shadcn/ui components are copied into your project — you own and can modify them freely
  • This is different from traditional component libraries where you import from an npm package you cannot modify
  • The form component integrates react-hook-form and Zod for end-to-end type-safe form validation
  • Design system customization happens through CSS custom properties in globals.css
  • shadcn/ui is built on Radix UI primitives, inheriting full keyboard navigation and ARIA compliance
  • npx shadcn@latest add [component] adds only the components you need — no bloat
  • Wrap shadcn components in your own components to set project-specific defaults (e.g., default variant)
  • The cn() utility from lib/utils.ts merges class names and resolves Tailwind conflicts — use it everywhere

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading