shadcn/ui: The Component Library That Isn't a Library

Every few years, a component library takes over the React ecosystem. Bootstrap React, then Material UI, then Chakra UI, then Ant Design. Each one follows the same model: install a package, import components, fight with the styling system when you need to customize something. Then a new version ships, breaks your overrides, and you spend a week fixing things.
shadcn/ui said "what if we just skip all of that?" Instead of being a package you install, it is a collection of beautifully designed, accessible components that you copy directly into your project. You own the code. You modify it freely. There is no version to upgrade and no styling system to fight against. It launched in early 2023 and by 2024 it has become the default choice for new React projects in my workflow.
The Problem with Traditional Component Libraries
I have used every major React component library at this point, and they all share the same frustrations:
Version lock-in. Your app depends on v4.x of the library. v5.x ships with breaking changes. You now have a multi-day migration project that delivers zero user-facing value. Every major version bump of Material UI, Chakra UI, or Ant Design has cost me days of work fixing things that were working fine before.
Styling conflicts. Every library has its own theming system. Material UI uses the sx prop and a theme object. Chakra UI uses style props and a different theme object. Ant Design uses CSS-in-JS with their own token system. If you want a button that does not look like any of these systems, you are overriding internals you are not supposed to touch, and those overrides break on the next update.
You cannot customize internals. Need to change how a Dialog's overlay animation works? Need to add a custom aria attribute pattern to a Select component? Need to modify the keyboard navigation behavior of a Menu? With a traditional library, you are limited to the props they expose. If the customization you need is not anticipated by the library authors, you are stuck writing a wrapper component that fights against the library's internals.
How shadcn/ui Is Different
shadcn/ui is fundamentally different. It is not a package you install. It is a CLI that copies component source code into your project. The components are built on two foundations:
- Radix UI Primitives: unstyled, accessible, headless UI components that handle all the complex accessibility and interaction logic (focus management, keyboard navigation, screen reader support, WAI-ARIA patterns).
- Tailwind CSS: utility-first styling that lives directly in the component markup.
When you add a component, you get the actual source code in your project. Not a reference to a node_modules package. The real, editable, deletable code. You can read it, understand it, modify it, and it never changes unless you change it.
Getting Started
Setup is quick. In a Next.js project with Tailwind already configured, you run the init command. This walks you through configuration: TypeScript or JavaScript, your preferred style, color scheme, and where to put components. It creates a components.json file that stores your preferences:
{
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
Now add components as you need them. Each add command copies the component source code into your components/ui directory. After adding button, dialog, form, input, and select, your project structure looks like:
components/
ui/
button.tsx
dialog.tsx
form.tsx
input.tsx
select.tsx
Those are real files you own. Open button.tsx and you will see clean, readable code built on Radix and Tailwind.
Theming with CSS Variables
shadcn/ui uses CSS custom properties for theming. Your globals.css file includes variable definitions for light and dark modes:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
/* ... dark mode overrides */
}
}
Change the primary color in one place and every component updates. Want a completely different color scheme? Swap the variable values. The shadcn/ui website has a theme editor that generates these variable blocks for you.
Customizing Components
This is where the "not a library" model truly shines. Let me show you the Button component and how you can extend it:
// components/ui/button.tsx (what shadcn generates)
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",
},
}
);
Want to add a "success" variant? Just add it to the variants object. Want to change the border radius? Edit the base classes. Want to add a loading state with a spinner? Add a prop and conditional rendering. There is no library API limiting what you can do. It is your code.
The Dialog component is another great example of the power of ownership. The base Dialog wraps Radix's Dialog primitive with Tailwind styling. Need a custom close animation? Edit the transition classes. Need the overlay to be blurred instead of dimmed? Change one class. Need a custom focus trap behavior? You have the full Radix API available since the source is right there in your project.
The Form Component
The Form component integrates React Hook Form and Zod validation into a clean, composable pattern:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const formSchema = z.object({
username: z.string().min(2, "Username must be at least 2 characters"),
email: z.string().email("Invalid email address"),
});
function ProfileForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: { username: "", email: "" },
});
function onSubmit(values) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="bryanortiz" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
Accessible form fields, validation error messages, and proper labeling, all wired up with Zod for type-safe validation. And again, you own every piece of this. Customize the error message styling, change the label position, add help text, whatever your design requires.
Comparison with Traditional Libraries
Material UI. Comprehensive, battle-tested, huge ecosystem. But heavyweight (over 300KB for common components), opinionated about Google's Material Design aesthetic, and the sx prop / styled-components / theme system adds complexity. Customizing beyond Material Design's visual language is painful.
Chakra UI. Excellent developer experience with style props, good accessibility defaults. But it is still a package you depend on. Version upgrades have broken my projects. The styling system is unique to Chakra, so your knowledge does not transfer.
Ant Design. Enterprise-grade with a massive component catalog. But the styling is deeply embedded and difficult to override. The default aesthetic screams "Ant Design," and escaping that look requires significant effort.
shadcn/ui. You own the code. Customization is unlimited. It uses Tailwind, which is transferable knowledge. The components are thin wrappers around Radix primitives, so accessibility is excellent. The tradeoff: you are responsible for maintenance and updates. There is no "npm update" that gives you the latest improvements. You check the website for new components or updates and manually apply what you want.
Why "Not a Library" Is the Right Model
After a year of using shadcn/ui across multiple projects, I am convinced this is the right approach for production applications. The component library model made sense when building UIs was harder and developers needed more hand-holding. In 2024, with Radix handling accessibility, Tailwind handling styling, and TypeScript handling type safety, the main thing a component library adds is a layer of abstraction between you and your own UI code.
shadcn/ui removes that layer. You get beautiful, accessible, well-structured starting points. Then you own them. Your design system grows organically from actual project needs rather than being constrained by a library author's API decisions.
The component library is dead. Long live the component collection.