Tailwind v4 Config: Where It Went When tailwind.config.js Didn't
This repo has zero JS config files. All 42 design tokens live in a CSS @theme block, and the four-line PostCSS file is the entire build pipeline.
Tailwind CSS v4 config confuses people who've used v3, for a specific reason: the file they're looking for doesn't exist. There is no tailwind.config.js, tailwind.config.ts, or any JS config anywhere in this codebase — the entire theme lives in 42 CSS custom properties inside globals.css, and the four-line PostCSS setup that makes it work is the whole build pipeline. Here's exactly where every piece of v4 configuration actually lives, and the two places CSS-first config doesn't fully replace the old file.
The short answer
Tailwind v4 configures itself in CSS, not JavaScript: an @theme block inside your main stylesheet replaces theme.extend in tailwind.config.js, and @plugin/@import directives replace the plugins/content arrays. A tailwind.config.js still works if you keep one and reference it with @config "./tailwind.config.js"; above your @theme block, but a v4 project doesn't need one at all — this codebase has zero JS config files and a 133-line globals.css doing everything a config file used to.
What moved where, v3 to v4
v3 (tailwind.config.js) | v4 (CSS) | This repo's version |
|---|---|---|
theme.extend.colors | @theme { --color-*: ...; } | 24 color custom properties (--color-primary, --color-primary-25…950, --color-gray-25…950) |
theme.extend.fontFamily | @theme { --font-*: ...; } | --font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif; |
theme.screens | @theme { --breakpoint-*: ...; } | Two custom breakpoints: --breakpoint-2xsm: 375px and --breakpoint-xsm: 425px |
content: [...] (file globs to scan) | Automatic — Tailwind scans your project by default | Not set; v4's automatic detection is unconfigured here |
plugins: [...] | @plugin "plugin-name"; in the CSS file | Zero @plugin lines — no plugins installed |
darkMode: "class" | @custom-variant dark (&:where(...)); | @custom-variant dark (&:where(.dark, .dark *)); |
A separate postcss.config.js wiring tailwindcss as a PostCSS plugin | Same idea, different package name | @tailwindcss/postcss — the whole file is 5 lines |
The whole config, in the actual file
/* src/app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-primary: #465fff;
--color-primary-25: #f2f7ff;
/* … 22 more color custom properties … */
--breakpoint-2xsm: 375px;
--breakpoint-xsm: 425px;
}
That @theme block is the entire design-token configuration for a 111-product storefront: 42 custom properties total, covering a primary brand scale, a full gray scale, semantic text/stroke tokens, a success scale, and the two custom breakpoints. Nothing outside this block configures Tailwind's design tokens anywhere in the repository.
// postcss.config.mjs — the entire build pipeline
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Compare that to the v3-era setup this replaces: a tailwind.config.js with a content array to prevent the class scanner from missing files, a theme.extend object, a plugins array, and tailwindcss/autoprefixer both listed in postcss.config.js. v4 folds the scanning, the plugin loading, and the theme into one CSS-native pipeline — which is also why npx tailwindcss init (the v3 command that generates the config file) doesn't produce anything meaningful to add to this project.
What replaced the content array
A v3 config's content array told Tailwind which files to scan for class names, and forgetting a glob pattern was a classic "why isn't my class generating" bug — the utility exists, but the scanner never saw the file using it. v4 detects your project's files automatically, respecting .gitignore, with no array to keep in sync as new folders appear. This repository never configures a content glob anywhere, and it doesn't need to: src/app, src/components, and src/data — the only three places a Tailwind class name appears in this codebase — are covered by the same automatic detection that scans everything else, and a @source directive (v4's manual override for a location the automatic scan can't see, such as a class name assembled in a separate workspace package) has never been necessary here because this is a single Next.js app, not a monorepo with shared UI packages living outside it.
The one thing custom breakpoints in @theme don't do automatically
The --breakpoint-xsm: 425px token registers an xsm: variant Tailwind utilities can use (xsm:flex, xsm:hidden). It does not retroactively rewrite hand-written CSS that already hardcodes a breakpoint. This repo's own .container rule, three lines below the @theme block, sets its own max-width at 425px via a plain @media (min-width: 425px) query — the same pixel value as --breakpoint-xsm, written independently, because a hand-authored @media rule and a Tailwind variant are two different mechanisms that happen to agree on a number. Renaming --breakpoint-xsm later would silently stop matching the container's own breakpoint unless someone updates both — @theme tokens configure Tailwind's generated utilities, not arbitrary CSS you write elsewhere in the same file.
Where CSS-first config genuinely can't do what the JS file did
Two things a tailwind.config.js could do that @theme alone cannot:
- Computed or conditional values. A v3 config is a JavaScript module — it can read
process.env, loop over an array to generate a color scale, or import a shared design-token package.@themeis static CSS; every value is a literal. If your tokens need computation, you still need a@configreference to a JS file, or a build step that generates the CSS. corePlugins,safelist, andseparator. These three v3 options have no CSS-first equivalent in v4 at all — not moved, just gone. A project that relied onsafelistto keep dynamically-built class names from being purged needs a different strategy under v4 (this repo has no dynamic class-name construction, so it never needed one).
Everything else this codebase used to configure in JS — colors, fonts, breakpoints, dark mode, plugins — has a direct CSS-first home, which is why tailwind.config.js was deletable rather than merely optional.
The PostCSS plugin name changed too
One more difference worth calling out explicitly, since it silently breaks a copy-pasted v3 setup: the PostCSS plugin package itself is different in v4. v3 projects wired tailwindcss directly into postcss.config.js's plugins array. v4 splits the PostCSS integration into its own package, @tailwindcss/postcss, and that's the only plugin this repository's postcss.config.mjs lists — no autoprefixer alongside it, because v4 handles vendor prefixing internally. A postcss.config.js copied from an older v3 project and left unedited will either error immediately (the old tailwindcss PostCSS export path was removed) or silently fail to process any Tailwind classes at all, depending on what else is installed — worth checking first if utilities aren't generating and the @theme block itself looks correct.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
bg-primary (or any custom color/utility) generates nothing, no error | Turbopack does not hot-reload @theme changes | Restart npm run dev after editing color/token variables in globals.css |
Adding a tailwind.config.js back "to be safe" has no effect | v4 doesn't read a JS config file automatically — it needs an explicit @config directive | Either delete the unused file, or add @config "./tailwind.config.js"; above @theme if you actually need it |
A plugin's classes (e.g., a typography or forms plugin) don't apply after npm install | The @plugin "name"; line is missing from the CSS file — v4 plugins are declared in CSS, not a plugins array | Add @plugin "@tailwindcss/typography"; (or the relevant plugin) directly in globals.css |
npx tailwindcss init produces a file that does nothing | The command still exists from v3 muscle memory, but v4's CSS-first setup doesn't consume that file's shape | Skip it; configure directly in @theme instead |
A custom breakpoint variant works in utility classes but a hand-written @media rule at "the same" width doesn't match it | @theme --breakpoint-* and a literal @media (min-width: …) are two independent mechanisms | Reference the same pixel value deliberately in both places, or move the hand-written rule to use the generated variant instead of a raw media query |
Frequently asked questions
Do I need to delete tailwind.config.js when upgrading to v4?
No, but you don't need to keep it either. If you keep it, add @config "./tailwind.config.js"; before your @theme block or v4 will ignore it silently. This codebase went the other way — no JS file at all — because nothing in its config needed anything a static @theme couldn't express.
Where do I put dark mode configuration in v4?
@custom-variant dark (&:where(.dark, .dark *)); in your CSS file, replacing v3's darkMode: "class" option. This repo's dark mode is class-based, toggled by adding .dark to <html>, and that one line is its entire configuration.
Can I still use plugins like @tailwindcss/typography in v4?
Yes, via an @plugin "@tailwindcss/typography"; line in the same CSS file as your @theme block, after installing the package. This repo has zero @plugin lines because it uses no Tailwind plugins — every utility comes from the core.
Why doesn't changing a color in @theme show up immediately in dev?
Tailwind v4 with Turbopack doesn't hot-reload @theme/@plugin changes. The dev server keeps serving the previously-generated utilities until you restart it — a full restart, not a browser refresh, is required after editing design tokens.
Templates in this post
ASoc Ledger, ASoc Lens and ASoc Magnet all run this same CSS-first @theme setup — no config file, one globals.css, restyle the whole product by editing custom properties.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
