shadcn/ui — Complete Setup, Customization, and Component Guide
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 initThe CLI asks about your framework, TypeScript usage, and styling preferences. It creates:
components/ui/— component files you ownlib/utils.ts— thecn()utility- Updates
globals.csswith 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 toastEach 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/buttonthen 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-formfor forms — the Form component is built around it - Forgetting to add
<Toaster />from sonner to your layout when usingtoast()
Best Practices
- Customize the CSS variables in
globals.cssbefore 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
formcomponent 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 fromlib/utils.tsmerges class names and resolves Tailwind conflicts — use it everywhere
Advertisement