Tailwind CSS v4
Detect the version first
This is the highest-value step. v3 and v4 configure completely differently, and guidance for one is actively wrong for the other.
bash# Check the installed major version npm ls tailwindcss --depth=0 2>/dev/null || grep '"tailwindcss"' package.json
| Signal | Version |
|---|---|
@import "tailwindcss"; in CSS | v4 |
@tailwind base; @tailwind components; @tailwind utilities; | v3 |
@theme { } block in CSS | v4 |
tailwind.config.js with a content: [...] array | v3 (or a v4 project using the compat layer) |
@config "./tailwind.config.js"; | v4 loading a legacy config |
@tailwindcss/postcss or @tailwindcss/vite dependency | v4 |
autoprefixer + postcss-import required in postcss config | v3 |
Never write a tailwind.config.js for a v4 project by default. v4 reads
configuration from CSS. A hand-added config file is silently ignored unless
explicitly loaded with @config.
v4 setup
css/* app.css — the single entry point */ @import "tailwindcss";
That one line replaces the three @tailwind directives, and it also handles
postcss-import and autoprefixer internally — remove both from the PostCSS config.
Vite:
js// vite.config.js import tailwindcss from "@tailwindcss/vite"; export default { plugins: [tailwindcss()] };
PostCSS:
js// postcss.config.mjs export default { plugins: { "@tailwindcss/postcss": {} } };
Configuration is CSS-first
Design tokens go in @theme. Every entry generates the matching utilities and
exposes a real CSS custom property.
css@import "tailwindcss"; @theme { --color-brand: oklch(0.62 0.19 250); --color-brand-muted: oklch(0.72 0.08 250); --font-display: "IBM Plex Sans", sans-serif; --radius-card: 14px; --spacing-gutter: 2.75rem; --ease-out-soft: cubic-bezier(0.22, 0.61, 0.36, 1); }
--color-brand yields bg-brand, text-brand, border-brand, … and is readable
anywhere as var(--color-brand). That dual nature is the main v4 win: tokens are no
longer trapped inside a JS config.
Namespaces map to utility families — --color-*, --font-*, --text-*,
--spacing-*, --radius-*, --shadow-*, --ease-*, --animate-*, --breakpoint-*.
Override a whole namespace, or everything:
css@theme { --color-*: initial; /* drop the default palette */ --color-ink: #18181b; }
No content array. v4 detects source files automatically, honoring
.gitignore and skipping binaries. Point it at extra sources only when needed:
css@source "../packages/ui/src"; /* add a path outside the project root */ @source not "../legacy"; /* exclude */ @source inline("bg-red-500 bg-red-600"); /* the v4 replacement for safelist */
Migrating v3 → v4
Run the codemod first, then review the diff:
bashnpx @tailwindcss/upgrade
The renames that break silently:
| v3 | v4 |
|---|---|
shadow-sm | shadow-xs |
shadow | shadow-sm |
rounded-sm | rounded-xs |
rounded | rounded-sm |
blur-sm | blur-xs |
outline-none | outline-hidden |
bg-opacity-50 | bg-black/50 (slash opacity only) |
flex-shrink-0 | shrink-0 |
The bare shadow → shadow-sm and rounded → rounded-sm shifts are the dangerous
pair: the old class names still exist but now mean one step smaller, so a v3
codebase silently loses a step of elevation and radius everywhere rather than erroring.
Other behavior changes to expect:
- Default border color is now
currentColor, notgray-200.borderalone inherits text color — set the color explicitly. - Default ring is
1px currentColor, was3pxblue.ring→ring-3to keep v3 sizing. - Space-between uses a different selector; sibling-margin edge cases can shift.
- Hover only applies where the device actually supports hover.
- Variant stacking now reads left-to-right.
Dynamic class names
The rule survives from v3, because the scanner still reads source text statically:
jsx// BROKEN — never generated <div className={`text-${color}-500`} /> // CORRECT — complete class names, statically visible const TONE = { danger: "text-red-500", success: "text-emerald-500", }; <div className={TONE[tone]} />
v4 does support dynamic values on many utilities without config —
grid-cols-15, w-17, mt-23 work arbitrarily. What cannot be assembled is the
class name string. For genuinely runtime-only values, use an inline style or a CSS
variable, not string concatenation:
jsx<div className="w-(--w)" style={{ "--w": `${pct}%` }} />
Practical patterns
Dark mode. v4 defaults to prefers-color-scheme. For class-based toggling:
css@custom-variant dark (&:where(.dark, .dark *));
Component extraction order. Prefer a real component over @apply. When you do
need shared CSS, v4 wants @utility:
css@utility card { border-radius: var(--radius-card); background: var(--color-surface); }
Heavy @apply use forfeits the point of utilities and inflates CSS — reach for it
last, not first.
Arbitrary values stay available for one-offs: top-[117px],
bg-[oklch(0.7_0.1_200)], grid-cols-[1fr_auto].
Common failures
- Classes not generating → a dynamically-built class name, or a source file outside
auto-detection (add
@source). - A
tailwind.config.jshaving no effect → v4 project without@config. - Build errors after upgrade →
postcss-import/autoprefixerstill in the PostCSS chain; v4 includes both. - Borders/rings looking wrong post-migration → the
currentColorandring-1default changes above. - Elevation subtly flattened post-migration → the
shadow/roundedrename shift.
Related skills
visual-style-presets— pick a direction, then encode its tokens in@themebeautiful-shadows— exact layered shadows as arbitrary utilities


