Shadcn UI from Beginner to Expert: A Learning Path
If you've spent any time in the React ecosystem recently, you've likely heard of Shadcn UI. It has rapidly become one of the most popular ways to build user interfaces in React and Next.js applications. But Shadcn UI isn't a traditional component library — it's something entirely different, and understanding that distinction is the key to mastering it. In this tutorial, we'll walk through a complete learning path, from absolute beginner to expert, with practical examples at every stage.
What Is Shadcn UI?
Shadcn UI is a collection of reusable, accessible components that you copy and paste into your application rather than install as an npm package. Built on top of Radix UI primitives and styled with Tailwind CSS, it gives you full ownership of the component code. There's no node_modules dependency to lock you in, no opaque API to fight against, and no theme provider that hides styling decisions from you.
When you add a Shadcn component, the source code lands directly in your project — typically under components/ui/ — and you can modify it however you like. This "build-your-own-library" approach is what makes Shadcn so powerful.
Why It Matters
- Full ownership: The code lives in your repo. You can rename props, change styles, or rewrite logic without forking a library.
- Accessibility built-in: Radix UI handles keyboard navigation, focus management, and ARIA attributes for you.
- Consistent theming: CSS variables drive the design tokens, making dark mode and brand customization trivial.
- No version lock-in: Updates are opt-in. You choose when to pull new changes.
- Tree-shakeable by nature: You only ship the components you actually use.
Prerequisites and Setup
Before diving in, you should be comfortable with React, TypeScript, and Tailwind CSS. Shadcn UI works best with Next.js, Vite, or Remix, but Next.js is the most common starting point. Let's set up a fresh Next.js project and initialize Shadcn UI.
Step 1: Create a New Project
npx create-next-app@latest my-shadcn-app
cd my-shadcn-app
During setup, select TypeScript, Tailwind CSS, and the App Router. These choices align with Shadcn's defaults.
Step 2: Initialize Shadcn UI
npx shadcn@latest init
The CLI will ask a few configuration questions. You can choose a base color (such as Slate, Gray, or Zinc) and whether to use CSS variables for theming. Say yes to CSS variables — this is what enables runtime theme switching.
This command creates a components.json file at the root of your project, which configures how Shadcn behaves:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
Step 3: Add Your First Component
npx shadcn@latest add button
This command downloads the Button component source into components/ui/button.tsx. Open the file and you'll see plain TypeScript and Tailwind classes — nothing hidden, nothing magical.
Beginner: Using Core Components
Now that you're set up, let's build something. We'll create a simple card with a button and an input. First, add the components you need:
npx shadcn@latest add card input label
Then use them in a page:
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";
export default function NewsletterSignup() {
return (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Subscribe</CardTitle>
<CardDescription>Get weekly updates in your inbox.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" />
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button>Subscribe</Button>
</CardFooter>
</Card>
);
}
Notice how every component accepts a className prop. This is intentional — Shadcn merges your classes with the defaults using the cn() utility (a thin wrapper around clsx and tailwind-merge). This means you can override any style without fighting the library.
Understanding the Button Variants
Open components/ui/button.tsx and look at the buttonVariants function. It uses cva (Class Variance Authority) to define variants:
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
This is one of the most important patterns to understand. Variants are just Tailwind class strings mapped to keys. To add a new variant, you edit this object directly. No library API to learn — it's just an object.
Intermediate: Theming and Customization
Shadcn's theming system is built on CSS variables defined in your global CSS file. Open app/globals.css and you'll see something like this:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
These variables are HSL values (without the hsl() wrapper) so Tailwind can apply opacity modifiers like bg-primary/90. The tailwind.config.ts file maps them to Tailwind color tokens:
export default {
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
};
Implementing Dark Mode
To toggle dark mode, you need to add or remove the dark class on the html element. The next-themes package makes this straightforward:
npm install next-themes
Create a theme provider component:
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
Wrap your root layout:
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
Now add a toggle button using Shadcn's dropdown menu:
npx shadcn@latest add dropdown-menu
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Customizing Brand Colors
To rebrand the entire app, simply change the CSS variable values. For example, to use a violet primary color:
@layer base {
:root {
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
}
.dark {
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
}
}
Every component that references bg-primary or text-primary-foreground instantly updates. This is the power of token-based theming.
Intermediate: Building Forms
Forms are where Shadcn truly shines when combined with React Hook Form and Zod. Let's build a validated form. First, install the dependencies and add the necessary components:
npm install react-hook-form @hookform/resolvers zod
npx shadcn@latest add form select toast
Define a Zod schema and build the form:
"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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const formSchema = z.object({
username: z.string().min(2, "Username must be at least 2 characters."),
role: z.enum(["admin", "user", "guest"], {
required_error: "Please select a role.",
}),
});
type FormValues = z.infer<typeof formSchema>;
export function UserForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
});
function onSubmit(values: FormValues) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 w-[350px]">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
<SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
The Form component is a thin wrapper around React Hook Form's context. The FormField render prop pattern gives you full control over the markup while automatically wiring up validation, error messages, and accessibility attributes.
Advanced: Creating Custom Components
Once you understand the patterns Shadcn uses, you can build your own components that follow the same conventions. Let's create a custom StatCard component that fits naturally alongside the rest of your UI library.
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
interface StatCardProps {
title: string;
value: string | number;
icon: LucideIcon;
description?: string;
trend?: {
value: number;
positive: boolean;
};
className?: string;
}
export function StatCard({
title,
value,
icon: Icon,
description,
trend,
className,
}: StatCardProps) {
return (
<Card className={cn("w-full", className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
{trend && (
<p className={cn(
"text-xs mt-1",
trend.positive ? "text-green-600" : "text-red-600"
)}>
{trend.positive ? "↑" : "↓"} {Math.abs(trend.value)}% from last month
</p>
)}
</CardContent>
</Card>
);
}
Usage is clean and idiomatic:
import { Users, DollarSign, Activity } from "lucide-react";
import { StatCard } from "@/components/stat-card";
export function Dashboard() {
return (
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Revenue"
value="$45,231"
icon={DollarSign}
trend={{ value: 20.1, positive: true }}
/>
<StatCard
title="Active Users"
value={2350}
icon={Users}
trend={{ value: 5.2, positive: false }}
/>
<StatCard
title="Server Load"
value="68%"
icon={Activity}
description="Average across all instances"
/>
</div>
);
}
Advanced: Building a Data Table
One of the most requested features in any application is a sortable, filterable data table. Shadcn provides a DataTable pattern built on TanStack Table. Let's build one step by step.
npm install @tanstack/react-table
npx shadcn@latest add table
First, define your column types and the table component:
"use client";
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<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>
);
}
Now define your data and columns:
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/data-table";
interface Payment {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
}
const columns: ColumnDef<Payment>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "email", header: "Email" },
{
accessorKey: "amount",
header: "Amount",
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>;
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
const colors: Record<string, string> = {
success: "text-green-600",
failed: "text-red-600",
pending: "text-yellow-600",
processing: "text-blue-600",
};
return <span className={colors[status]}>{status}</span>;
},
},
];
const data: Payment[] = [
{ id: "m5gr84i9", amount: 316, status: "success", email: "ken99@example.com" },
{ id: "3u1reuv4", amount: 242, status: "success", email: "abe45@example.com" },
{ id: "derv1ws0", amount: 837, status: "processing", email: "monsieur44@example.com" },
];
export function PaymentsTable() {
return <DataTable columns={columns} data={data} />;
}
From here, you can extend the table with sorting, filtering, pagination, and row selection by adding the corresponding TanStack Table features and wiring them into the component. The Shadcn documentation provides recipes for each of these.
Best Practices
1. Treat components/ui as Your Library
The components/ui/ directory should contain only the raw, reusable primitives. Application-specific components that compose these primitives belong elsewhere — for example, components/ or feature-based folders. This separation keeps your UI library clean and portable.
2. Don't Be Afraid to Edit Component Source
The whole point of Shadcn is ownership. If a component doesn't fit your needs, change it. Add a variant, rename a prop, or restructure the JSX. Just be deliberate about it and document why you deviated from the default.
3. Use the cn() Utility Consistently
When building custom components, always use cn() to merge classes. This ensures consumers can override styles via the className prop:
import { cn } from "@/lib/utils";
export function Badge({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<span className={cn("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs", className)}>
{children}
</span>
);
}
4. Leverage cva for Complex Variants
For components with multiple variant axes (size, color, shape), use cva just like Shadcn does. It keeps your variant logic declarative and type-safe:
import { cva, type VariantProps } from "class-variance-authority";
const badgeVariants = cva("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive text-destructive-foreground",
outline: "border border-input",
},
},
defaultVariants: {
variant: "default",
},
});
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
5. Keep Accessibility Intact
When modifying Radix-based components, preserve the data attributes and ARIA props that Radix injects. Avoid removing aria-label, role, or keyboard event handlers. Test with a screen reader periodically to ensure you haven't broken the accessibility contract.
6. Version Control Your Components
Since the component source lives in your repo, commit it like any other code. When Shadcn releases updates, review the changelog and manually integrate changes that matter to you. This gives you a clear audit trail of what you've customized.
7. Use the CLI's diff Command
Shadcn provides a diff command to see what has changed between your local component and the latest upstream version:
npx shadcn@latest diff button
This is invaluable when deciding whether to pull in updates.
Expert: Monorepo and Design System Distribution
At the expert level, you may want to share your customized Shadcn components across multiple projects via a monorepo or internal npm package. The components.json configuration supports custom registries, allowing you to host your own component source and distribute it with the same CLI workflow.
You can publish a custom registry as a JSON file that maps component names to source files:
{
"name": "my-design-system",
"items": [
{
"name": "stat-card",
"type": "registry:ui",
"files": ["stat-card.tsx"],
"dependencies": ["@/components/ui/card"]
}
]
}
Then consume it in any project:
npx shadcn@latest add https://my-registry.com/stat-card.json
This approach lets you build a true design system on top of Shadcn's foundations, with the same copy-paste philosophy but centralized governance.
Conclusion
Shadcn UI represents a fundamental shift in how we think about component libraries. Instead of treating UI components as opaque dependencies, it treats them as starting points — code you own, understand, and evolve. By progressing from basic component usage through theming, forms, custom components, data tables, and finally monorepo distribution, you've seen the full spectrum of what Shadcn makes possible. The real expertise comes not from memorizing an API, but from internalizing the patterns: CSS variable theming, cva variants, Radix primitives, and the cn() merging utility. Once those click, you can build any component, in any style, with accessibility and consistency baked in. Start small, edit freely, and let your component library grow organically alongside your application.