| name | tailwind-v4 |
| description | Tailwind CSS v4 conventions for real projects — CSS-first configuration with @theme, automatic content detection, the v3-to-v4 migration diffs, and dynamic class-name safety. Use when writing Tailwind utilities, setting up or upgrading Tailwind, defining design tokens as Tailwind theme values, debugging classes that do not generate, or when unsure whether a project is on v3 or v4. |
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.
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
@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:
import tailwindcss from "@tailwindcss/vite";
export default { plugins: [tailwindcss()] };
PostCSS:
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.
@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:
@theme {
--color-*: initial;
--color-ink: #18181b;
}
No content array. v4 detects source files automatically, honoring
.gitignore and skipping binaries. Point it at extra sources only when needed:
@source "../packages/ui/src";
@source not "../legacy";
@source inline("bg-red-500 bg-red-600");
Migrating v3 → v4
Run the codemod first, then review the diff:
npx @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, not gray-200. border alone inherits
text color — set the color explicitly.
- Default ring is
1px currentColor, was 3px blue. ring → ring-3 to 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:
<div className={`text-${color}-500`} />
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:
<div className="w-(--w)" style={{ "--w": `${pct}%` }} />
Practical patterns
Dark mode. v4 defaults to prefers-color-scheme. For class-based toggling:
@custom-variant dark (&:where(.dark, .dark *));
Component extraction order. Prefer a real component over @apply. When you do
need shared CSS, v4 wants @utility:
@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.js having no effect → v4 project without @config.
- Build errors after upgrade →
postcss-import/autoprefixer still in the PostCSS
chain; v4 includes both.
- Borders/rings looking wrong post-migration → the
currentColor and ring-1
default changes above.
- Elevation subtly flattened post-migration → the
shadow/rounded rename shift.
Related skills
visual-style-presets — pick a direction, then encode its tokens in @theme
beautiful-shadows — exact layered shadows as arbitrary utilities