NoWaterProgramming

What a tailwind.config.js Still Does in Tailwind v4: 19 Config Keys, Measured

Tailwind v4 stopped reading tailwind.config.js automatically, and the @config directive brings it back. We probed all 19 keys of a real v3 config against Tailwind 4.3.3, one key per build, and diffed the output CSS. Most keys work. Four are dropped in silence. And the documented list of what does not work is wrong about safelist.

15 min read
Share:

Measured against Tailwind CSS 4.3.3 on 2026-09-19.

A tailwind.config.js sitting in your project root does nothing under Tailwind v4. Not "less than it used to". Nothing: we built a project with the file present and a project with the file deleted, and the two output stylesheets were byte-identical, down to the same SHA-256. The file only comes back to life when a @config line in your CSS points at it.

Once it does, most of it still works. We put all 19 keys of a realistic v3 config through one build each and diffed the CSS. Fourteen keys behave. Four are dropped without a single word on stderr. One is honored but quietly rewritten in a way that breaks every class in your markup.

And the documented list of what v4 cannot do is wrong. The docs name corePlugins, safelist and separator as unsupported.1 Two of those are correct. safelist works.

The setup

The probe is deliberately small, because the question is about the config loader and nothing else.

  • tailwindcss 4.3.3, @tailwindcss/cli 4.3.3, @tailwindcss/postcss 4.3.3, Node 24.18.0.
  • One HTML file carrying every probe class.
  • One CSS entry per config key: @import "tailwindcss" plus a single @config line, so exactly one key is under test per build. A config that sets four things at once tells you nothing about which of the four moved the output.
  • The baseline is @import "tailwindcss" with no @config and no config file anywhere on disk. It comes out at 53,006 bytes.

From there the verdict on a key is mechanical. If its probe reaches the output CSS, the key works. If the output is byte-identical to the baseline, the key did nothing at all.

That last test is stricter than eyeballing the file, and it is the one that makes the "silently ignored" rows trustworthy. corePlugins: { float: false } did not merely fail to remove float-left; it produced a stylesheet indistinguishable from one built with no config in the picture.

The table

config keyunder @configevidence
config file on disk, no @config linenothing at allbyte-identical to a build with no config file present
contentworks, and adds to auto-detectiona class living only in a file outside the project appears; auto-detected classes all stay
theme.extend.colorsworks#123456 in output
theme.extend.spacingworks3.75rem in output
theme.extend.screensworks@media (width >= 1111px), and the new breakpoint extends .container too
theme (full replace, not extend)works.bg-red-500 disappears
theme.container center + paddingworksemits a second .container rule
pluginsworksthe plugin's addUtilities output is present
presetsworksthe preset's theme colour is present
darkMode: 'class'works.dark\:underline:is(.dark *) replaces the media query
important: trueworks!important count goes from 8 to 665
safelist, string entriesworks, though the docs say otherwiseclasses present in no source file are emitted
safelist, { pattern: /re/ } entriessilently ignorednone of the covered classes are emitted, empty stderr
blocklistworksfont-style: italic disappears
prefix: 'tw-'honored, then rewrittenwarns, becomes the v4 tw: variant
corePluginssilently ignoredbyte-identical to baseline
separator: '_'silently ignoredbyte-identical to baseline
futureno effectbyte-identical to baseline
variants (a v2 key)silently ignoredbyte-identical to baseline

The rest of this article is the rows that are not obvious.

The file on disk does nothing, and that is the whole migration trap

This is the row that costs people an afternoon, because the failure has no symptom. You upgrade, your config file is still sitting there, your build still succeeds, and your custom colours are simply gone. Nothing warns you. There is no "unused config file" notice, because from v4's point of view there is no config file. It is an unreferenced .js file in your repo like any other.

The fix is one line at the top of your CSS:

@import "tailwindcss";
@config "../../tailwind.config.js";
css

The path is resolved relative to the CSS file, not to the project root. And if you get it wrong, that at least is loud: a missing target is a hard build error with a non-zero exit, not a warning.

Error: Can't resolve '../configs/nope.js' in '.../lab/src'

Two @config lines in one stylesheet is legal, by the way. Both configs load, both themes merge, exit 0, no warning. That is convenient for a monorepo and a trap for anyone who forgets the second one is there.

safelist works, and the docs say it does not

The v4 @config documentation names exactly three keys as unsupported: corePlugins, safelist and separator.1 The safelist entry points you to @source inline() instead.2 Search for the question and the same three-item list comes back in every write-up on the first page of results.

Two of the three hold up. safelist does not. On 4.3.3:

// tailwind.config.js
module.exports = {
  safelist: ['skew-y-12', 'bg-fuchsia-700'],
}
js

Both classes appear in the output. Neither appears in any source file the build scanned, so there is no other route by which they could have been generated. The same result comes out of the PostCSS plugin, so this is the engine's behaviour and not a quirk of the CLI.

What is actually broken is the form the docs never mention, the v3 object entry:

safelist: [
  { pattern: /^text-(emerald|sky)-(400|600)$/ },
  { pattern: /^grid-cols-(7|11)$/, variants: ['md'] },
]
js

Not one of the six classes those two patterns cover was emitted. Exit code 0, empty stderr, no diagnostic of any kind. If you are carrying a v3 config whose safelist is patterns, and you read the docs, you would conclude your whole safelist is dead and go rewrite it. Half of it is fine. The pattern half is the part that vanished, and it vanished without telling you.

This is the sharper and more useful sentence: string entries survive, pattern entries do not. We would still move a pattern safelist to @source inline() with its brace expansion,2 because that is the path the project intends to support and a behaviour the docs disown could change in a patch release. But knowing which half broke is the difference between a targeted fix and a rewrite.

theme.container is not gone either

The upgrade guide says the container utility's center and padding options "no longer exist" in v4.3 We have quoted that line ourselves, in our Tailwind v3 to v4 migration write-up.

Under @config they exist. This config:

theme: {
  container: { center: true, padding: '2.5rem' },
}
js

produces a second .container rule appended after the generated breakpoint ladder:

.container {
  width: 100%;
  @media (width >= 40rem) { max-width: 40rem; }
  /* ... */
}
.container {
  margin-inline: auto;
  padding-inline: 2.5rem;
}
css

Which reframes what the upgrade tool is doing. npx @tailwindcss/upgrade rewrites a v3 theme.container block into an @utility container rule in your CSS, keeping your real values. That is not the tool compensating for a capability v4 lost. It is the tool moving you off @config and onto the CSS-first API, because @config is a compatibility shim and the project would rather you did not live in it.1

prefix is honored, and that is worse than being ignored

prefix is the one key where doing the work is more destructive than dropping it.

module.exports = { prefix: 'tw-' }
js

The build warns:

The prefix "tw" is invalid. Prefixes must be lowercase ASCII letters (a-z)
only and is written as a variant before all utilities. We have fixed up the
prefix for you. Remove the trailing `-` to silence this warning.

Then it applies the prefix, in the v4 syntax. In v3 a prefix was a string glued to the front of the class name, so you wrote tw-underline. In v4 the prefix is a variant, so it is tw:underline.3 The config is honored; it is your markup that is now wrong.

The output makes this concrete. tw:underline is emitted. tw-underline is not. And the plain underline in the same HTML is no longer emitted either, because with a prefix set every utility has to carry it.

So a v3 project whose classes are all tw--prefixed, migrated by adding one @config line, builds clean with a single yellow warning and renders completely unstyled. The stylesheet in our probe fell from 53,006 bytes to 4,713. That is the shape of this bug in production: not a build failure, a blank-looking page.

content adds sources, it does not replace them

In v3, content was the entire list of files Tailwind looked at. In v4, source detection is automatic, and the question is what an explicit content array does to that.

It adds. We put a class in a file outside the project directory and listed only that file in content. The class appeared, and every class found by automatic detection was still there. The config's content behaves like an @source line, not like a replacement for the scanner.

That is the friendly outcome, and it is worth knowing because the alternative would be catastrophic and silent: a narrow v3 content array that replaced auto-detection would strip most of your CSS. It does not.

CSS beats JavaScript, in both directions

If a token is defined in both places, @theme in your CSS wins:

@import "tailwindcss";
@config "../tailwind.config.js";   /* theme.extend.colors.probe = #123456 */
@theme {
  --color-probe: #999999;
}
css

#999999 is in the output. #123456 is not present anywhere in the file.

Source order does not change it. Putting the @theme block above the @config line gives the same answer. This is not a cascade, where the last declaration wins; the CSS-first API that v4 was built around is simply authoritative over the compatibility shim.4 Useful when you are migrating a large theme one token at a time, since you can move a colour into @theme and leave the old JavaScript entry in place without them fighting.

The four silent keys

corePlugins, separator, variants and the safelist pattern form all produced output byte-identical to the baseline, with an empty stderr in every case. Only prefix printed a diagnostic across the entire matrix.

Three of them deserve a sentence each:

  • corePlugins was how you disabled a utility group in v3. { float: false } left float: left in the output. There is no v4 equivalent. That is the answer, not a workaround waiting to be found.
  • separator was the character between a variant and a utility. v4 hardcodes :.
  • variants has been dead since v3 made all variants available by default. It is in this table only because real configs still carry it.

And one that is not a bug at all: future: { hoverOnlyWhenSupported: true } produced a byte-identical build because v4 already emits @media (hover: hover) around hover: utilities unconditionally. The flag is not ignored so much as redundant. The future block in a v3 config is generally a list of things that became the default, which is why it is safe to delete rather than port.

What this site does

This blog runs Tailwind v4 with no JavaScript config at all. The theme is an @theme block, dark mode is handled in CSS, and there is no @config line to point anywhere. That is not a recommendation earned by suffering; the site was built after v4 shipped, so it never had a config to migrate.

The measurement above is the useful half. If you have a working tailwind.config.js and a deadline, @config will get you building today and the table tells you which four keys will lie to you on the way. Then move tokens into @theme at whatever pace you like, knowing the CSS side wins every collision.

Where this is weak

  • One version. Everything here is 4.3.3. The safelist result in particular is behaviour the docs disown, so it is exactly the kind of thing that can change in a patch release without a changelog entry. Re-run the probe on your own version before betting on it.
  • CLI and PostCSS only. Not tested through @tailwindcss/vite. All three load the same engine package, and CLI and PostCSS agreed on every probe, so we would be surprised by a difference, but we did not measure it.
  • Reaching the output is not the same as full fidelity. These probes ask whether a key affects the generated CSS. They do not verify that every nuance of a key's v3 semantics survived. theme.extend.screens works, for instance, but we checked that the breakpoint generates, not that every interaction between custom screens and the default set matches v3 exactly.
  • A realistic config, not an exhaustive one. Nineteen keys covers what sits in real v3 projects. It is not every key the v3 schema accepted.

Doing this on your own project

  1. Add @config "path/to/tailwind.config.js"; under your @import "tailwindcss";. The path is relative to the CSS file.
  2. Build, and read stderr. A warning here is almost certainly prefix.
  3. If your markup uses a prefix, stop and deal with it first. Every class needs to change from tw-foo to tw:foo, and until it does your build succeeds while your pages render unstyled.
  4. Grep your config for corePlugins, separator, variants, and safelist entries that are objects rather than strings. Those four are gone and will not tell you.
  5. Diff the output stylesheet against your v3 build. Byte counts are a blunt instrument but a large unexplained drop means something did not load.
  6. Migrate tokens into @theme gradually. CSS wins collisions, so you can move one colour at a time with the old entry still in the config.

FAQ

Does Tailwind v4 still read tailwind.config.js?

Not automatically. A tailwind.config.js in your project root is ignored entirely; we verified the output is byte-identical to a build with no config file present. It is loaded only when a @config "path/to/tailwind.config.js"; line in your CSS points at it.1

What does the @config directive do in Tailwind v4?

It loads a legacy v3 JavaScript config file. The docs describe it as existing "solely for compatibility with Tailwind CSS v3.x".1 The path resolves relative to the CSS file containing the directive, and a path that does not resolve is a hard build error rather than a warning.

Which tailwind.config.js options do not work in v4?

Measured on 4.3.3: corePlugins, separator, variants, and safelist entries written as { pattern: /re/ } objects. All four are dropped with no warning. future has no effect either, but only because its flags became defaults. The documented list of unsupported keys is corePlugins, safelist and separator;1 our measurement agrees on two of the three and finds that string safelist entries do work.

Does safelist work in Tailwind v4?

String entries do, on 4.3.3, through both the CLI and the PostCSS plugin, despite the documentation listing safelist as unsupported.1 Regex { pattern } entries do not and fail silently. The supported path is @source inline() in your CSS, which also handles brace expansion for generating class variations.2

What happened to theme.container's center and padding in v4?

The upgrade guide says they no longer exist.3 Loaded through @config they do work, emitting a .container rule with margin-inline: auto and your padding-inline value. The CSS-first replacement is an @utility container rule, which is what npx @tailwindcss/upgrade writes for you.3

Does prefix still work in Tailwind v4?

Yes, with a syntax change that will break your markup. v4 prefixes are variants, so tw-underline becomes tw:underline.3 A v3 config's prefix: 'tw-' is accepted with a warning about the trailing hyphen, then applied in the new form, which means a prefixed v3 codebase builds successfully and renders unstyled.

Does @theme override a JavaScript config theme?

Yes, and source order does not matter. A token defined in both an @theme block and the config's theme resolves to the @theme value, with the JavaScript value absent from the output. That makes an incremental migration safe: move tokens to CSS one at a time and leave the config entries in place.

Disclosure

The migration article linked above was written about selim.services, one of our own sites. The measurements in this article were taken on a throwaway project built for the purpose, not on any property of ours.

Sources

Checked 2026-09-19.

Sources

  1. Tailwind CSS functions and directives: the @config directive - that @config loads a legacy JavaScript-based configuration file, that it exists solely for compatibility with Tailwind CSS v3.x, and that the corePlugins, safelist and separator options from a JavaScript config are not supported in v4.

  2. Tailwind CSS: safelisting specific utilities - that @source inline() is the v4 mechanism for forcing Tailwind to generate classes that appear in no source file, and that it supports brace expansion for generating variations of a class.

  3. Tailwind CSS upgrade guide - that theme.container's center and padding options no longer exist and are replaced by an @utility container rule, and that the prefix option is now written as a variant before the utility name rather than as a string prepended to it.

  4. Tailwind CSS v4.0 announcement - that v4 replaces the JavaScript configuration file with a CSS-first configuration model in which the theme is declared with @theme and exposed as CSS variables.

Related Posts

13 min read
We ran npx @tailwindcss/upgrade on a production Astro site, moving Tailwind 3.4.19 to 4.3.3. The tool rewrote the PostCSS config, translated and deleted tailwind.config.mjs, and renamed classes across 31 files, all correctly. It also left two build-breaking errors specific to Astro, and the main stylesheet came out 28% larger gzipped.
By NoWaterProgramming Team
14 min read
How a pixel-comparison overlay actually works in the browser: why a wrapper div breaks mix-blend-mode, click-through without pointer-events juggling, keeping scroll on its fast path, and hardening per-origin persisted state.
By NoWaterProgramming Team
12 min read
Tailwind v4 moved configuration out of JavaScript and into CSS. What that changes for theming and dark mode, what the upgrade breaks, the browsers it drops, and the measured CSS size of a real site.
By NoWaterProgramming Team