Tailwind CSS v4 made configuration feel more like CSS, which is a bigger change than it sounds.
Older Tailwind projects often had a split brain. The visual decision lived in tailwind.config.js, the consuming code lived in class names, and the actual cascade lived somewhere else. That split was workable, but it made styling feel like a small compiler project. You changed a token in JavaScript, rebuilt the generated utilities, then inspected the result in CSS-shaped output.
Version 4 pulls more of that work into CSS itself. The import gets smaller:
@import "tailwindcss";
Theme configuration can live in CSS too:
@import "tailwindcss";
@theme {
--font-display: "Inter", "sans-serif";
--breakpoint-wide: 1440px;
--color-panel: oklch(0.98 0.01 250);
--color-panel-ink: oklch(0.22 0.03 250);
--ease-soft: cubic-bezier(0.2, 0, 0, 1);
}
That changes the editing loop. The configuration still feeds Tailwind’s utility system, but the file now looks like the medium it controls.
configuration was doing two jobs
Tailwind configuration has always carried two kinds of information.
The first kind is integration plumbing: content paths, plugins, presets, build details, and compatibility knobs. That belongs in a config layer because it describes how the tool runs.
The second kind is design language: colors, type scales, breakpoints, shadows, spacing, easing, and named values. That is closer to CSS. Those decisions affect the cascade, inherit through components, and eventually become visual behavior in the browser.
Putting both jobs in a JavaScript config file made sense historically, but it created a weird reading experience. A designer or frontend engineer reviewing a color decision had to leave CSS to understand the CSS. A code reviewer had to bounce between generated utilities and configuration objects. The design system was there, but it was stored in a format that made it feel one step removed.
The v4 approach reduces that translation.
css variables make the decision inspectable
@theme uses CSS custom properties, which gives the names a useful second life.
That means a token has a life outside Tailwind’s class generator. It can be referenced directly, inspected in devtools, overridden in scoped contexts, and reasoned about with the rest of the cascade.
@theme {
--color-callout-bg: oklch(0.97 0.03 210);
--color-callout-border: oklch(0.72 0.08 210);
}
.docs-callout {
background: var(--color-callout-bg);
border-color: var(--color-callout-border);
}
That is a small thing until a project has a lot of surfaces. Then it matters that a token is readable in the same place as the exception that uses it.
I still like utilities for most layout and component work. But there are always places where raw CSS is clearer: prose styling, MDX content, embedded demos, charts, third-party widgets, and browser states that do not map cleanly to a class list. Shared variables make those places less awkward.
fewer files can mean fewer excuses
Moving configuration closer to CSS does not magically improve a design system.
It does remove one common excuse: “I did not know where that value came from.” When a project has theme values in CSS, component overrides in CSS, and generated utilities reading from the same source, drift is easier to notice.
Bad drift looks like this:
@theme {
--color-accent: oklch(0.67 0.19 255);
}
.pricing-card {
border-color: #3b82f6;
}
The hardcoded value might be intentional, but now the question is obvious during review. Should this use the accent token? Is it a new semantic color? Did someone paste from devtools and move on?
That is the kind of friction I want from a styling system. It should make shortcuts visible without turning every small visual adjustment into a ceremony.
css-first does not mean config-free
I do not read v4 as an argument that every project should delete configuration discipline.
Large teams still need naming rules. They still need to decide whether a token is primitive, semantic, or component-specific. They still need migration plans when a color changes. They still need to decide whether a plugin belongs in the project or whether the project has grown a private dialect of Tailwind.
CSS-first configuration can even make a mess feel more official. A giant @theme block with hundreds of one-off values is still a junk drawer. It is a CSS-flavored junk drawer, but the maintenance problem is the same.
The useful constraint is to ask what each theme value means.
@theme {
--color-blue-500: oklch(0.62 0.19 255);
--color-action-primary: var(--color-blue-500);
--color-link: var(--color-blue-500);
}
Primitive values help with palettes. Semantic values help with product meaning. Mixing those layers casually is how a redesign turns into a scavenger hunt.
automatic detection changes setup work
Tailwind v4 also reduces some setup noise around content detection. That matters because Tailwind projects used to accumulate brittle path lists.
In a small app, those lists are fine. In a mixed site with Astro pages, MDX content, React islands, package workspaces, generated routes, and component folders, content configuration can become another place for stale assumptions. A class fails to generate, someone adds a broader glob, and eventually the project is scanning more than it needs.
Automatic detection does not remove the need to understand the build. It changes the default. The first setup can be smaller, and the project only reaches for explicit configuration when the default misses something real.
That is the right direction for a tool like Tailwind. The common path should be quiet. The escape hatch should still exist.
the migration is a chance to delete names
The best part of a styling upgrade is rarely the new syntax. It is the excuse to read the old decisions.
When moving a project toward v4, I would look for:
- colors named after how they look instead of what they mean
- breakpoints that no longer match real layouts
- spacing tokens added for one component and never reused
- shadows copied between components with tiny differences
- plugin usage that solved a problem the platform now handles
- arbitrary values that became permanent without being named
Some values should move into @theme. Some should stay local. Some should disappear.
That last option is the one teams skip. Configuration tends to grow because deleting a token feels riskier than adding a new one. A migration gives you a reason to ask whether the value still earns its place.
the real win is reading fewer dialects
Frontend work already asks people to read too many dialects: HTML, CSS, TypeScript, framework conventions, design tool names, browser APIs, accessibility rules, build configuration, and whatever pattern the last engineer preferred.
Tailwind v4 does not make that disappear. It does make one part of the stack feel less translated. Theme decisions can live in CSS syntax. Imports can look like imports. Variables can be variables.
That is why the change feels good to me. It is not because CSS is morally better than JavaScript for every configuration problem. It is because styling decisions become easier to inspect when they live closer to styling.
The browser speaks CSS at the end of the pipeline. Tailwind v4 lets more of the conversation happen in that language.
Related posts

About Jeremy London
Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.