Web Development

Tailwind CSS v4 without a config file

Tailwind v4 moves theme configuration out of JavaScript and into CSS. Here is how @theme, @utility and @plugin replace tailwind.config.js in a real project.

Mohammed Saqib7 min read
Abstract teal gradient cover illustration for an article about Tailwind CSS v4 configuration.
Photo by Mohammed Saqib on Original artwork · Original artwork

Tailwind v3 projects have a familiar shape: a tailwind.config.js that grows a theme.extend block, a content array that has to be kept in sync with your folder structure, and a plugins array. Tailwind v4 deletes most of that. Theme tokens live in CSS, content detection is automatic, and the JavaScript config is an opt-in compatibility shim rather than the entry point. The migration is mostly mechanical, but a few directives behave differently than the v3 equivalents they replace, and the difference between @theme and @theme inline is the one that costs people an afternoon.

What actually replaced the config file

In v4 the stylesheet is the configuration. You import Tailwind once and then declare tokens with @theme:

@import "tailwindcss";
 
@theme {
  --color-brand: oklch(0.68 0.12 195);
  --font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
  --radius-card: 0.75rem;
  --breakpoint-3xl: 120rem;
}

Every entry does two things. It emits a real CSS custom property on :root, and it registers a utility namespace with the compiler. --color-brand gives you bg-brand, text-brand, border-brand, ring-brand/40 and the rest of the colour utilities. --breakpoint-3xl gives you a 3xl: variant. The namespace prefix is what matters: --color-* feeds colour utilities, --font-* feeds font-*, --spacing-* feeds padding, margin and gap, --radius-* feeds rounded-*.

That mapping is the whole mental model. In v3 you wrote a nested JavaScript object and Tailwind flattened it into class names. In v4 you write flat variable names and the prefix tells the compiler which utility family they belong to. There is no theme.extend because there is nothing to extend — declaring --color-brand adds to the default palette, and declaring --color-red-500 overrides it.

To start from an empty palette instead of the defaults, clear a namespace first:

@theme {
  --color-*: initial;
  --color-ink: oklch(0.16 0 0);
  --color-paper: oklch(0.99 0 0);
}

--color-*: initial removes every built-in colour, so bg-slate-500 stops compiling and the only colours available are the two you defined. This is worth doing on a design system where an accidental text-purple-400 should be a build-time mistake rather than a code review comment.

The inline variant, and why theme toggles need it

@theme writes the literal value into :root. That is correct when the token is a constant, and wrong when the token is a pointer to something that changes.

Consider a light and dark theme driven by a class on <html>. The colour that bg-background should produce is different in each mode, so the actual values have to live somewhere that a .dark selector can override:

:root {
  --background: oklch(0.99 0 0);
  --foreground: oklch(0.16 0 0);
}
 
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
}
 
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

With inline, the compiler substitutes the right-hand side directly into each generated utility, so bg-background compiles to background-color: var(--background). The utility now resolves through your variable at paint time and follows the .dark override.

Without inline, Tailwind would instead emit --color-background: var(--background) into :root and generate background-color: var(--color-background). That extra hop looks harmless, but --color-background was resolved once against the :root value. Toggling .dark changes --background and the utility keeps painting the light colour. The symptom is a theme switch that updates some elements and not others, which sends people hunting through specificity rules for a problem that is one keyword deep.

The rule of thumb: constants go in @theme, indirection goes in @theme inline.

There is a second-order consequence worth knowing about. Because @theme inline does not emit the token to :root, the variable --color-background never exists in the browser. If some other stylesheet, a third-party widget, or a piece of runtime JavaScript reads getComputedStyle(el).getPropertyValue("--color-background"), it gets an empty string. Read the underlying --background instead, which is the variable that actually holds a value. Component libraries that ship their own tokens tend to expect the emitted form, so when you wire one up, check which of the two shapes it reads before assuming the integration is broken.

Spacing, and the tokens you no longer enumerate

The spacing scale changed shape in a way that removes a lot of configuration. Rather than a fixed list of steps, v4 derives spacing utilities from a single base value:

@theme {
  --spacing: 0.25rem;
}

Every spacing utility is computed as a multiple of that base, which means p-7, mt-13 and gap-23 all compile even though nobody declared them. In v3 those were either missing or added by hand to theme.extend.spacing. If your design system runs on a 4px grid, this is exactly what you want. If it runs on something else, change the one value and the whole scale moves with it.

The same generative approach applies to opacity modifiers and colour mixing. bg-brand/40 does not require a brand-40 token to exist; the compiler mixes the colour at build time. That is why clearing --color-*: initial is safe — you lose the default palette, not the ability to produce shades of what you kept.

Custom utilities and variants

v3 needed a plugin and the addUtilities API to register a custom class that composed with variants. v4 has a directive:

@utility bg-dot-grid {
  background-image: radial-gradient(currentColor 1px, transparent 1px);
  background-size: 22px 22px;
}
 
@utility text-balance {
  text-wrap: balance;
}

Anything declared with @utility participates in the full variant system, so hover:bg-dot-grid and md:text-balance work without extra registration. This matters more than it sounds: a plain CSS class in @layer components does not get variants, and that gap is why so many v3 codebases carried a small plugin file purely to register three utilities.

Custom variants get their own directive:

@custom-variant dark (&:is(.dark *));
@custom-variant pointer-fine (@media (pointer: fine));

The first is the class-based dark mode strategy, written out explicitly rather than set through a darkMode: "class" option. The second lets you write pointer-fine:hover:scale-105 to keep a hover effect off touch devices.

What each v3 concept maps to

Tailwind v3 Tailwind v4 Notes
theme.extend.colors @theme { --color-* } Flat names, namespace prefix decides the utility family
theme.colors (replace) --color-*: initial then declare Clearing the namespace is explicit
darkMode: "class" @custom-variant dark (&:is(.dark *)) No implicit strategy setting
content: [...] automatic Source detection walks the project, respecting .gitignore
plugins: [typography] @plugin "@tailwindcss/typography" Loaded from CSS, path resolved like an import
addUtilities plugin @utility Variants work without registration
screens.3xl --breakpoint-3xl Same namespace idea as colours
postcss.config entry @tailwindcss/postcss Single plugin, no autoprefixer needed

The content row is the one that changes day-to-day behaviour most. v4 scans the project automatically and ignores anything in .gitignore, which removes a whole class of "why is this class missing in production" bugs. The cost is that a template living outside your repo — inside a linked package, say — is invisible until you add @source "../ui-kit/src" to point at it.

Where the migration gets bumpy

Three things account for most of the friction.

The PostCSS setup changed. v4 ships its PostCSS integration as a separate package and no longer needs autoprefixer or postcss-import, because both are handled internally. A config that still lists them will usually work and will definitely be slower.

Arbitrary values that referenced config paths are gone. w-[theme(spacing.72)] was a v3 escape hatch; in v4 the token is already a CSS variable, so it becomes w-[var(--spacing-72)] — or more often, just a normal utility.

Plugins that introspect the config are the real breakage. A plugin calling theme("colors.brand.500") depends on a resolved config object that v4 does not construct in the same way. Well-maintained plugins have shipped v4 releases; unmaintained ones tend to need replacing with a handful of @utility declarations, which is usually less code than the plugin was.

For anything the directives do not cover, @config "./tailwind.config.js" still loads a legacy JavaScript config. Treat it as a migration aid with a deadline rather than a resting place — the v4 documentation is written against the CSS directives, and staying on the shim means translating every example you read.

Key takeaways

  • Theme tokens are flat CSS variables whose prefix decides which utility family they generate; there is no theme.extend because declaring a token already extends the defaults.
  • Use @theme inline whenever the token points at another variable, or class-based theme switching will silently paint one colour scheme.
  • @utility is the replacement for small utility plugins and gives you variant support for free; a plain CSS class does not.
  • Content detection is automatic and .gitignore-aware — reach for @source only when templates live outside the repository.
  • @config keeps a v3 JavaScript config working, which is useful for a staged migration and awkward as a permanent arrangement.

Frequently asked questions

Do I still need tailwind.config.js in Tailwind v4?
No. A JavaScript config is optional and only loaded if you explicitly point at it with the @config directive. New projects define their theme entirely in CSS using @theme, and the compiler reads that at build time.
What is the difference between @theme and @theme inline?
@theme emits the value into :root as a CSS variable and generates utilities from it. @theme inline substitutes the value directly into the generated utility instead of referencing the variable, which is what you want when the token itself points at another variable that changes per colour scheme.
Can I still use Tailwind plugins written for v3?
Many work unchanged through the @plugin directive, which loads a JavaScript plugin from CSS. Plugins that read or mutate the resolved config object are the ones most likely to break, since v4 no longer builds that object the same way.
#tailwind#css#tooling#design-systems
Share

Keep reading