shadcn/ui — Build Beautiful React UIs Without a Component Library Lock-In
How shadcn/ui works, why it's different from MUI or Chakra, and how to use it to build production UIs with full control over every component.
Every React developer has been through this cycle: pick a component library (Material UI, Chakra UI, Ant Design), build your app, then spend months fighting the library's opinions when your design diverges from theirs. Custom themes. Overriding styles with !important. Wrapping components in other components just to change padding. The library that saved you time initially now costs you time on every design iteration.
shadcn/ui takes a fundamentally different approach. It's not a library you install from npm. It's a collection of components you copy into your project. You own the code. You modify it directly. There's no abstraction layer between you and the implementation.
How It Works
shadcn/ui is built on Radix UI primitives (headless, accessible components) styled with Tailwind CSS. When you "install" a component, the CLI copies the source code into your project.
# Initialize shadcn/ui in your project
npx shadcn@latest init
# Add specific 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 table
npx shadcn@latest add dropdown-menu
After running npx shadcn@latest add button, you get a file at components/ui/button.tsx that looks like this:
// components/ui/button.tsx — this is YOUR file, not a node_module
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 whitespace-nowrap 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 };
Want to add a new variant? Edit the file. Want to change the border radius? Edit the file. Want to add an animation? Edit the file. No theme overrides. No styled() wrappers. Just code you own.
Using Components
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 function LoginForm() {
return (
<Card className="w-[400px]">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" />
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="ghost">Forgot password?</Button>
<Button>Sign In</Button>
</CardFooter>
</Card>
);
}
The API feels like a polished component library, but you can open any component file and see exactly what's happening. No magic.
Theming
shadcn/ui uses CSS custom properties for theming, defined in your global CSS file:
/ 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%;
--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%;
--ring: 222.2 84% 4.9%;
--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%;
/ ... dark mode values /
}
}
Changing your entire color scheme means updating these CSS variables. Every component references them. No prop drilling, no ThemeProvider component tree, no CSS-in-JS runtime.
The Key Components
shadcn/ui has 40+ components. Here are the ones you'll use most:
// Dialog (modal)
import {
Dialog, DialogContent, DialogDescription,
DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile here.
</DialogDescription>
</DialogHeader>
{/ Form content /}
</DialogContent>
</Dialog>
// Data Table with sorting and filtering
import {
Table, TableBody, TableCell,
TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-right">{user.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
// Command palette (⌘K)
import {
Command, CommandDialog, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem onSelect={() => navigate("/settings")}>
Settings
</CommandItem>
<CommandItem onSelect={() => navigate("/profile")}>
Profile
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
// Form with validation (react-hook-form + zod)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form, FormControl, FormDescription,
FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const schema = z.object({
username: z.string().min(2).max(30),
email: z.string().email(),
});
function ProfileForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { username: "", email: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<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>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
shadcn/ui vs Traditional Component Libraries
| Aspect | shadcn/ui | MUI / Chakra / Ant Design |
|---|---|---|
| Installation | Copy source code into project | npm install, import from package |
| Customization | Edit files directly | Theme objects, style overrides |
| Bundle size | Only what you use (tree-shakeable by default) | Library loaded even for unused components |
| Updates | Manual (re-run CLI to update components) | npm update |
| Lock-in | Zero — it's your code | High — tightly coupled API |
| Accessibility | Radix primitives (excellent) | Varies by library |
| Styling | Tailwind CSS | CSS-in-JS / Emotion / styled-components |
| Design consistency | You control it | Library controls it |
When NOT to Use shadcn/ui
You shouldn't use shadcn/ui if:- You need a design system that enforces strict consistency across a large team. shadcn/ui gives you freedom, which also means freedom to be inconsistent.
- You're prototyping fast and don't want to think about component styling at all. Something like Chakra UI where every component looks decent out of the box might be faster.
- Your team doesn't know Tailwind CSS. shadcn/ui is Tailwind through and through.
- You need components shadcn/ui doesn't have. The collection is growing but isn't as comprehensive as MUI or Ant Design.
- You have specific design requirements that don't match any library's defaults
- You want accessible components without the weight of a full library
- You're using Tailwind CSS already
- You want full control without building everything from scratch
- You're building a product, not a prototype
Adding Custom Variants
Since you own the code, extending components is straightforward:
// components/ui/button.tsx — add your own variants
const buttonVariants = cva(
"inline-flex items-center justify-center ...",
{
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",
// Add your own
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",
gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600",
},
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",
// Add your own
xl: "h-14 rounded-lg px-10 text-lg",
},
},
}
);
Now works with full TypeScript support.
shadcn/ui gets the abstraction level right — enough structure to be productive, enough access to be flexible. It's the component approach that respects the fact that most apps eventually outgrow their UI library. Build your next React UI with it and see how it compares to what you've been using. More component patterns and React guides at CodeUp.