Overview
Tailwind CSS v4 is a complete rewrite built on the Rust-based Oxide engine. Configuration moves from JavaScript to CSS via @theme.
Key v4 shifts:
- Config:
tailwind.config.js→ CSS@themeblock - Import:
@tailwind base/components/utilities→@import "tailwindcss" - Dark mode: automatic via
@media (prefers-color-scheme) - Content detection: automatic, no
contentarray needed
Browser support: Safari 16.4+, Chrome 111+, Firefox 128+
Installation
Vite (Recommended)
npm install tailwindcss @tailwindcss/vite
// vite.config.js
import tailwindcss from "@tailwindcss/vite";
export default {
plugins: [tailwindcss()],
};
PostCSS
npm install -D tailwindcss @tailwindcss/postcss
// postcss.config.js
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
CLI
npm install -D @tailwindcss/cli
npx @tailwindcss/cli -i input.css -o output.css --watch
Basic Setup
/* input.css */
@import "tailwindcss";
/* Your custom styles and @theme block below */
That's it. No @tailwind base/components/utilities directives—they're gone.
Design Token Architecture (v4)
Single source of truth: The @theme block in your main CSS file defines all design tokens. Every color, spacing value, and font becomes a CSS variable.
Token Definition Pattern
@import "tailwindcss";
@theme {
/* Replace, don't extend, the default palette */
--color-brand: oklch(65% 0.25 250);
--color-brand-dark: oklch(55% 0.25 250);
--color-bg: oklch(98% 0.01 250);
--color-surface: oklch(100% 0 250);
--color-text: oklch(20% 0.02 250);
--color-text-muted: oklch(50% 0.02 250);
/* Spacing scale */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-display: "Clash Display", sans-serif;
--font-body: "Satoshi", system-ui, sans-serif;
}
Why Replace Instead of Extend
The default Tailwind palette is generic. Replacing it with your semantic tokens:
- Prevents
bg-blue-500from leaking into a design that usesbg-brand-500 - Makes theme changes a token edit, not a class sweep
- Keeps the design system coherent
Failure mode: Hardcoding a hex in a component class:
/* BAD: This breaks the theme system */
.card {
background-color: #3b82f6; /* Can't change via @theme */
}
Correct:
/* GOOD: Theme change is one token edit */
@theme {
--color-card-bg: var(--color-surface);
}
.card {
background-color: var(--color-card-bg);
}
Token-to-Component Mapping
Component styles reference tokens, not raw values:
@layer components {
.btn {
background-color: var(--color-brand);
color: var(--color-surface);
padding: var(--spacing-sm) var(--spacing-md);
font-family: var(--font-display);
}
.btn:hover {
background-color: var(--color-brand-dark);
}
}
Result: Changing --color-brand in @theme updates every button site-wide. No search-and-replace.
The --color-* Namespace Rule
Any --color-* variable in @theme automatically generates utility classes:
@theme {
--color-primary: oklch(60% 0.18 250);
}
Now bg-primary, text-primary, border-primary all work. The engine maps:
--color-{name}→{prop}-{name}utilities
Dark Mode Decision Guide
v4 defaults to @media (prefers-color-scheme)—no config needed. But product requirements dictate the right strategy.
Three Strategies
| Strategy | Mechanism | Best For |
|----------|-----------|----------|
| Media query | @media (prefers-color-scheme: dark) | Static sites, blogs, no user preference |
| Manual toggle | .dark class on <html> | Apps with user theme preference |
| Data attribute | [data-theme="dark"] on <html> | Multiple themes (light/dark/sepia) |
Decision Logic
Does the product need user-chosen theme that survives reload?
├─ Yes → Use `.dark` class or `data-theme` attribute
│ Persist choice in localStorage
│ Sync with `<html class="dark">` or `<html data-theme="dark">`
│
└─ No → Media query is enough. Do nothing.
Manual Toggle Implementation
<!-- HTML -->
<html class="dark">
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
Content
</div>
</html>
// JavaScript toggle
function toggleDark() {
const html = document.documentElement;
const isDark = html.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
}
// Restore on load
const saved = localStorage.getItem("theme");
if (saved === "dark") {
document.documentElement.classList.add("dark");
}
Avoiding Flash-of-Wrong-Theme
On first paint, before JS runs, the page may flash the wrong theme. Fix:
<!-- Inline script before any CSS/JS -->
<script>
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (saved === "dark" || (!saved && prefersDark)) {
document.documentElement.classList.add("dark");
}
</script>
Place this in <head> before any stylesheets.
Why Not dark: on Every Color
Adding dark: to every utility duplicates tokens:
<!-- BAD: Duplicates token definitions -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-800">
Better: Define semantic tokens that invert automatically:
@theme {
--color-bg: oklch(100% 0 250);
--color-bg-dark: oklch(15% 0.02 250);
--color-text: oklch(20% 0.02 250);
--color-text-dark: oklch(90% 0.02 250);
}
<!-- Use semantic tokens, fewer dark: prefixes -->
<div class="bg-bg text-text">
Or use CSS color-scheme with automatic contrast:
@layer base {
:root {
color-scheme: light dark;
}
}
Bundle Size and Content Detection
Content Detection in v4 (@source)
v4 automatically scans your project. No content array needed. But you can explicitly add sources:
@import "tailwindcss";
@source "../components/**/*.{js,ts,jsx,tsx,vue,svelte}";
@source "../pages/**/*.{js,ts,jsx,tsx}";
Rule: Content scanning must cover all template files, not just JS. If you use Blade, EJS, Handlebars, or PHP templates, add them:
@source "../views/**/*.blade.php";
@source "../templates/**/*.html";
Why Unused Utilities Are Tree-Shaken
The Oxide engine generates only the utilities you actually use. Unused classes are never emitted.
What inflates output:
- Arbitrary values:
bg-[#3b82f6]prevents some optimizations because each arbitrary value is unique - Icon libraries: SVG icons in HTML add bulk
- Plugin CSS: Custom plugins that emit raw CSS (not utilities)
- Preflight: The base reset (~15KB)
Measuring Bundle Size
# Build and measure
npx @tailwindcss/cli -i input.css -o output.css
wc -c output.css # Byte count
# Compare with and without @source directives
Target: A typical v4 build is 10–30KB gzipped for a medium app.
Reducing Bundle Size
-
Use semantic tokens instead of arbitrary values:
<!-- BAD: Arbitrary value --> <div class="bg-[#3b82f6]"> <!-- GOOD: Token --> <div class="bg-brand"> -
Exclude unused plugin CSS:
/* Don't import full plugins if you only need one utility */ @plugin "@tailwindcss/typography"; /* Only if you need prose */ -
Use
@referencefor component styles:<style> @reference "../app.css"; /* Only what you @apply here */ </style>
Component-Layer Discipline
When a utility pattern repeats, decide between three approaches:
1. Plain Class (Default)
@layer components {
.btn {
display: inline-flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 0.5rem;
font-weight: 600;
}
}
Use when: The pattern is used in 3+ places and has no variants.
2. @utility Directive (v4)
@utility btn {
display: inline-flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: 0.5rem;
font-weight: 600;
}
Use when: You want the pattern to work with variants (hover:btn, dark:btn).
Failure in JSX-heavy codebases: @apply re-opens the specificity fight utilities were meant to end:
/* BAD: @apply in a component library */
.btn {
@apply bg-blue-500 text-white px-4 py-2;
}
Why it fails:
- The component CSS may load after Tailwind, overriding your utilities
- Specificity becomes unpredictable
- You're back to fighting CSS cascade instead of avoiding it
Correct: Define the component in @layer components with raw CSS, or use @utility if you need variant support.
3. Variant with @variant
@variant elevated {
box-shadow: var(--shadow-card);
background-color: var(--color-surface);
}
Use when: A state (like "elevated", "pressed", "selected") applies across multiple utilities.
Migration to v4 Checklist
| Symptom | v3 | v4 | Fix |
|---------|----|----|-----|
| Build fails, unknown directive | @tailwind base | @import "tailwindcss" | Replace all @tailwind directives |
| Config changes ignored | tailwind.config.js | @theme in CSS | Move config to CSS @theme block |
| extend not working | theme.extend in JS | CSS variables | Define variables directly in @theme |
| Custom utilities missing | @layer utilities | @utility | Rewrite with @utility directive |
| Old config needed | N/A | @config | Add @config "../../tailwind.config.js" (legacy only) |
| Plugin not loading | plugins: [] in JS | @plugin | Use @plugin "@tailwindcss/typography" |
| corePlugins error | corePlugins: [] | Not supported | Remove from config, use @theme flags |
| separator error | separator: "_" | Not supported | Use new arbitrary value syntax |
One-line summary:
@tailwind base/components/utilities→@import "tailwindcss"tailwind.config.js→ CSS@themeblockextend→ CSS variables in@theme@apply→@utility(for variant support)- Plugins →
@plugindirective
Framework Integration
Vue / Svelte Component Styles
In v4, styles in separate files don't see theme variables by default. Use @reference:
<template>
<h1>Hello</h1>
</template>
<style>
@reference "../app.css";
h1 {
@apply text-2xl font-bold text-red-500;
}
</style>
Next.js / Vite
No special config needed if using the official plugin:
// next.config.js or vite.config.js
import tailwindcss from "@tailwindcss/vite";
export default {
plugins: [tailwindcss()],
};
Common Issues
Missing Classes After Build
- Ensure
@sourcedirectives cover all template files - Check that the CSS file with
@themeis imported by your entry point
Dark Mode Not Applying
- For manual toggle: add
class="dark"to<html>, not<body> - For media: no config needed, just use
dark:utilities
Custom Utilities Not Working
- Use
@utilitydirective, not@layer utilities - Ensure the CSS file with
@themeis imported
Arbitrary Values Not Parsing
- v3:
bg-[--my-var] - v4:
bg-(--my-var)(parentheses, not brackets)
Best Practices
Do:
- Replace the default color palette with semantic tokens in
@theme - Use
@utilityfor reusable patterns that need variants - Let dark mode be automatic unless user preference is required
- Use
@sourceto explicitly scan non-JS templates - Define tokens once, reference everywhere via CSS variables
Don't:
- Hardcode hex values in component CSS
- Use
@applyin JSX-heavy component libraries - Add
dark:to every color utility - Create a
tailwind.config.jsfor new projects - Use Sass/Less/Stylus—they don't work with v4
Deep Dives
Load these reference files for detailed information:
- Theme Configuration —
@themedirective syntax, design tokens, colors, spacing, breakpoints, animations —references/theme.md - Custom Utilities & Variants —
@utility,@variantdirectives, utility classes reference —references/utilities-variants.md - Migration from v3 — Breaking changes, upgrade tool, new features, Preflight changes —
references/migration-v4.md