Chapter 12: Tailwind CSS v4
Tailwind CSS v4 is a complete rewrite — CSS-native configuration, 3.78x faster full builds, 100x+ faster incremental builds, and a first-party Vite plugin. This chapter covers setup, configuration, and the patterns that make Tailwind effective in a React + TypeScript codebase.
Setup with Vite
pnpm add tailwindcss @tailwindcss/vite
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
tailwindcss(),
// ... other plugins
],
});
/* src/styles/app.css */
@import "tailwindcss";
That is the entire setup. No tailwind.config.js. No PostCSS configuration. No content array. Tailwind v4 automatically discovers your files through the Vite plugin.
CSS-First Configuration
Tailwind v4 replaces tailwind.config.js with CSS @theme directives:
/* src/styles/app.css */
@import "tailwindcss";
@theme {
/* Colors */
--color-brand-50: oklch(0.97 0.01 250);
--color-brand-100: oklch(0.93 0.03 250);
--color-brand-200: oklch(0.87 0.06 250);
--color-brand-300: oklch(0.78 0.1 250);
--color-brand-400: oklch(0.68 0.15 250);
--color-brand-500: oklch(0.55 0.2 250);
--color-brand-600: oklch(0.48 0.2 250);
--color-brand-700: oklch(0.4 0.18 250);
--color-brand-800: oklch(0.33 0.15 250);
--color-brand-900: oklch(0.27 0.12 250);
--color-brand-950: oklch(0.2 0.08 250);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Spacing */
--spacing-18: 4.5rem;
--spacing-112: 28rem;
--spacing-128: 32rem;
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Breakpoints */
--breakpoint-3xl: 1920px;
/* Animations */
--animate-slide-in: slide-in 0.2s ease-out;
--animate-fade-in: fade-in 0.15s ease-in;
}
@keyframes slide-in {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
Every @theme variable automatically generates corresponding utility classes:
--color-brand-500→bg-brand-500,text-brand-500,border-brand-500--font-sans→font-sans--spacing-18→p-18,m-18,gap-18,w-18,h-18--animate-slide-in→animate-slide-in
Responsive Design
Tailwind's responsive design uses mobile-first breakpoints:
<div className="
grid
grid-cols-1 /* Mobile: 1 column */
sm:grid-cols-2 /* ≥640px: 2 columns */
lg:grid-cols-3 /* ≥1024px: 3 columns */
xl:grid-cols-4 /* ≥1280px: 4 columns */
gap-4
sm:gap-6
">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
Container Queries (New in v4)
Container queries let children respond to their parent's size, not the viewport:
{/* Parent defines itself as a container */}
<div className="@container">
{/* Children respond to the container's width */}
<div className="@sm:flex @sm:gap-4 @lg:grid @lg:grid-cols-2">
<div className="@sm:w-1/3">Sidebar</div>
<div className="@sm:w-2/3">Content</div>
</div>
</div>
This is particularly useful for reusable components that may be placed in sidebars, modals, or full-width areas.
Common UI Patterns
Cards
function ProjectCard({ project }: { project: Project }) {
return (
<div className="rounded-lg border bg-card p-6 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold leading-none tracking-tight">
{project.name}
</h3>
<p className="text-muted-foreground mt-2 text-sm">
{project.description}
</p>
</div>
<Badge variant={project.status === "active" ? "default" : "secondary"}>
{project.status}
</Badge>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
<span>{project.taskCount} tasks</span>
<span>Updated {formatRelative(project.updatedAt)}</span>
</div>
</div>
);
}
Sidebar Layout
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
{/* Sidebar */}
<aside className="hidden w-64 shrink-0 border-r bg-muted/40 md:block">
<nav className="flex h-full flex-col gap-2 p-4">
<SidebarNav />
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
);
}
Form Layout
function FormField({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<label className="text-sm font-medium leading-none">{label}</label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
function ProjectForm() {
return (
<form className="space-y-6">
<FormField label="Project Name" error={errors.name}>
<input
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter project name"
/>
</FormField>
<FormField label="Description">
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Describe your project"
rows={4}
/>
</FormField>
<div className="flex justify-end gap-3">
<Button variant="outline" type="button">Cancel</Button>
<Button type="submit">Create Project</Button>
</div>
</form>
);
}
Tailwind Best Practices for React
1. Extract Components, Not Classes
// ✅ Extract a React component
function StatusBadge({ status }: { status: ProjectStatus }) {
const styles = {
active: "bg-green-100 text-green-800",
archived: "bg-gray-100 text-gray-800",
completed: "bg-blue-100 text-blue-800",
};
return (
<span className={cn("rounded-full px-2.5 py-0.5 text-xs font-medium", styles[status])}>
{status}
</span>
);
}
// ❌ Extract to CSS with @apply (fight against Tailwind's philosophy)
// .status-badge { @apply rounded-full px-2.5 py-0.5 text-xs font-medium; }
2. Use cn() for Conditional and Mergeable Classes
function Card({ className, highlighted, ...props }: CardProps) {
return (
<div
className={cn(
"rounded-lg border bg-card p-6",
highlighted && "ring-2 ring-primary",
className // Allow consumers to override
)}
{...props}
/>
);
}
3. Design Tokens via CSS Variables
Keep consistent spacing, colors, and sizing through your theme variables rather than hard-coding values:
// ✅ Use theme tokens
<div className="bg-primary text-primary-foreground rounded-lg p-6">
// ❌ Hard-coded values
<div className="bg-blue-600 text-white rounded-[12px] p-[24px]">
Monorepo Considerations
When using Tailwind in a monorepo with shared packages, use the @source directive to tell Tailwind to scan library files:
/* apps/web/src/styles/app.css */
@import "tailwindcss";
/* Include shared packages in class scanning */
@source "../../packages/ui/src";
@source "../../packages/shared/src";
Summary
- ✅ Tailwind CSS v4 uses CSS-native
@themedirectives instead of JavaScript config - ✅ First-party Vite plugin for optimal integration
- ✅ Container queries enable component-level responsive design
- ✅ Extract React components, not CSS classes
- ✅ Use
cn()for conditional and override-friendly class merging - ✅ Design tokens through CSS variables keep styling consistent
- ✅
@sourcedirectives handle monorepo class scanning