Building UI with shadcn/ui and Tailwind CSS
Why this matters
UI component libraries come in two flavors: packages you install and packages you own. Most libraries (@mui/material, antd) live in node_modules — you import them but can't change them without forking. That creates friction whenever your design diverges from what the library provides.
shadcn/ui takes a different approach: npx shadcn@latest add button copies the component's source code directly into your repo. You own it. You can modify it, add variants, and extend it without forking a package or fighting !important overrides.
Tailwind CSS v4 is the foundation. It dropped the JavaScript config file entirely — theming is now pure CSS variables. This means:
- Dark mode is a CSS class, not a media query.
- Rebranding means changing one CSS variable value, not a global find-and-replace.
- No build-time configuration overhead.
What goes wrong without this setup:
- Hardcoding color values like
bg-blue-500means theming is a search-and-replace across hundreds of files. - Without
cn(), conditional class composition produces duplicate/conflicting utilities that the browser can't resolve predictably. - Without
cva(), variant logic leaks into components as long chains of ternaries.
Tailwind v4 (CSS-first, no tailwind.config.js) is the foundation; shadcn/ui generates components on top of it. Both depend on cn() and cva() from conventions.md.
1. Install Tailwind v4
pnpm add tailwindcss @tailwindcss/vite
pnpm add tw-animate-cssOnly these packages. Do not install postcss, autoprefixer, or a tailwindcss CLI — v4 handles all of that.
2. Vite plugin — vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});3. CSS entry — src/styles.css
One import replaces v3's three @tailwind directives:
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));@custom-variant dark makes the dark: prefix activate when an ancestor has .dark, controlled in JS rather than the OS media query.
Import once at the entry:
// src/main.tsx
import "./styles.css";4. shadcn@latest init
npx shadcn@latest initChoose Neutral base color. The CLI writes components.json, the cn() util at the utils alias, and the theming token blocks into styles.css.
Resulting components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "~/shared/components",
"utils": "~/shared/lib/utils",
"ui": "~/shared/components/ui",
"lib": "~/shared/lib",
"hooks": "~/shared/hooks"
}
}Field by field:
style: "new-york"— current default (tighter spacing,lucideicons).rsc: false— client React (Vite SPA), not RSC. Settrueonly for Next.js App Router.tailwind.config: ""— empty on purpose. v4 has no JS config file.tailwind.cssVariables: true— components reference semantic utilities (bg-primary), not literal palette colors.aliases— point to your~/shared/...layout soadd buttonwritessrc/shared/components/ui/button.tsx.
5. Theme tokens — the two-layer pattern
See the shadcn/ui theming docs for the full token reference. Here's what matters for our setup:
The CLI writes raw oklch values on :root / .dark, then aliases them into Tailwind utilities via @theme inline. You get class names like bg-primary that swap automatically on .dark — no component changes needed. The token block comes from shadcn@latest init --base-color neutral and should not be edited by hand after init (re-run init to regenerate).
Three extension patterns we actually use:
Add a semantic token (e.g. success):
/* :root and .dark blocks */
:root { --success: oklch(0.65 0.15 145); }
.dark { --success: oklch(0.55 0.12 145); }
/* @theme inline block */
@theme inline { --color-success: var(--success); }Then use bg-success, text-success, etc.
Rebrand: only --primary (and its -foreground) needs to change. Everything else follows.
Dark mode class: @custom-variant dark (&:is(.dark *)) means .dark on an ancestor — not a media query — controls dark mode. The JS theme toggle adds/removes .dark on <html>.
6. Dark mode toggle — light / dark / system
import { useEffect, useState } from "react";
type Preference = "light" | "dark" | "system";
export function useTheme() {
const [preference, setPreference] = useState<Preference>(
() => (localStorage.getItem("theme") as Preference) ?? "system",
);
useEffect(() => {
localStorage.setItem("theme", preference);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function apply(next: "light" | "dark") {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
}
const resolved = preference === "system" ? (mq.matches ? "dark" : "light") : preference;
apply(resolved);
if (preference !== "system") return;
const onChange = (e: MediaQueryListEvent) => apply(e.matches ? "dark" : "light");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [preference]);
return { preference, setPreference };
}To avoid a flash on first paint, set .dark on <html> synchronously via an inline script in your HTML head reading the same localStorage key, before React mounts.
For persisted preference, swap useState for a Zustand store with persist (see zustand.md).
7. The cn() helper
init writes src/shared/lib/utils.ts:
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}clsxflattens conditional class inputs.tailwind-mergeresolves conflicting Tailwind utilities, last one wins.
cn("px-4 py-2", "px-6"); // → "py-2 px-6"
cn("text-red-500", cond && "text-blue-500");This is what lets callers pass className to override component defaults rather than fight them.
8. Adding components
npx shadcn@latest add button card dialog input labelThe CLI reads components.json, writes each component to your ui alias (src/shared/components/ui/button.tsx), and installs missing peer deps (radix-ui, class-variance-authority, etc).
Ownership model: there's no @shadcn/ui package. add copies source into your repo and you own it. Edit freely. "Updating" = re-running add and reconciling the diff with git diff, not bumping a version.
9. The cva variant pattern
Generated src/shared/components/ui/button.tsx:
import { cva, type VariantProps } from "class-variance-authority";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import { cn } from "~/shared/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs 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 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };Three load-bearing patterns:
data-slot="button"— stable attribute other components target for styling (e.g. button group:in-data-[slot=button-group]:rounded-lg). Add one to every component.asChild+Slot— merge styles onto the caller's child element instead of rendering a<button>:<Button asChild><a href="/">Home</a></Button>.cn(buttonVariants({ variant, size, className }))— passingclassNameintobuttonVariantslets cva place it last so caller overrides win, thencnde-conflicts.- Variant class strings reference theme tokens (
bg-primary), never literal colors — that's what makes theming work.
To add a project variant, extend the cva map directly (you own the file):
variant: {
// ...
success: "bg-success text-white shadow-xs hover:bg-success/90",
}Then add --success to :root and .dark, plus --color-success: var(--success); in @theme inline. Consumers get <Button variant="success"> with full type safety.
10. Updating a component
There's no auto-upgrade:
git add -A && git commit -m "checkpoint before shadcn update"
npx shadcn@latest add button # overwrite
git diff src/shared/components/ui/button.tsxKeep custom variants in clearly-separated blocks in the cva map so the diff is easy to reconcile.
v3 → v4 migration cheatsheet
v3 (tailwind.config.js) | v4 (CSS) |
|---|---|
content: [...] | (gone) — sources auto-detected |
theme.extend.colors.primary | @theme { --color-primary: ... } (or @theme inline) |
theme.extend.fontFamily.sans | @theme { --font-sans: ... } |
theme.extend.borderRadius | @theme { --radius-*: ... } |
plugins: [require("@tailwindcss/forms")] | @plugin "@tailwindcss/forms"; in CSS |
darkMode: "class" | @custom-variant dark (&:is(.dark *)); in CSS |
@tailwind base/components/utilities | @import "tailwindcss"; |
Delete postcss.config.js and tailwind.config.js. Update prettier.config.js to use tailwindStylesheet: "./src/styles.css".
Monorepo: @acme/ui package (optional)
Lift the shadcn layer into a libs/ui workspace package so multiple apps share it. Layout:
libs/ui/
components.json # aliases use relative names: "components", "lib/utils", ...
package.json
index.ts # barrel re-exports
lib/utils.ts # the one cn()
styles/theme.css # owns the token blocks
components/ui/ # generated primitives
hooks/libs/ui/package.json:
{
"name": "@acme/ui",
"version": "0.0.0",
"type": "module",
"sideEffects": ["**/*.css"],
"exports": {
".": "./index.ts",
"./theme.css": "./styles/theme.css"
},
"scripts": {
"shadcn": "pnpm dlx shadcn@latest"
}
}Consuming app imports both the components and the theme:
@import "tailwindcss";
@import "@acme/ui/theme.css";import { Button } from "@acme/ui";Add components from any package directory: pnpm --filter @acme/ui shadcn add button card.
Verify
<Button>Default</Button>
<Button variant="outline" size="sm">Small outline</Button>
<Button className="w-full">Caller override wins</Button>
<Button asChild><a href="/">Link styled as button</a></Button>Toggle .dark on <html> in devtools — background, text, border, and primary colors flip with no component changes.