Tailwind v4 and the Config File I Deleted
Design tokens in CSS, and why oklch is worth the unfamiliarity
Tailwind v4 moved configuration out of JavaScript and into CSS. I expected that to be a cosmetic change and mostly a matter of relocating a file. It turned out to change how I think about theming, largely because a token defined in CSS is a thing the browser knows about at runtime, and a token defined in a config file was only ever a thing the build step knew about.
01Configuration Moved Into the Stylesheet
The old setup had a JavaScript config that the build read to generate utility classes. Colors, spacing, radii, and breakpoints were all values in an object, and nothing in the browser had any idea they existed. The new setup declares them as custom properties inside a theme block in the stylesheet, and Tailwind generates utilities from those declarations. The immediate practical difference is that every design token is now also a live CSS variable. I can read one in a component's inline style, interpolate one into a gradient, hand one to a canvas or WebGL background so the animated colors match the theme, and change one at runtime without a rebuild. On this site the loading bar at the top of the page is colored by referencing the primary token directly, which means it tracks any future palette change with no code touched. The second difference is that there is one place to look. Design decisions live in the stylesheet next to the base layer that uses them, rather than in a config file in the project root that you open twice a month and have to reorient inside every time.
02Why I Switched to oklch
Every color on this site is declared in oklch, which looks alien for about a day and then becomes hard to give up. The reason is that its lightness channel is perceptually uniform. Two colors with the same lightness value actually look equally bright to a human eye, which is not true of hsl, where a yellow and a blue at the same stated lightness can differ dramatically in perceived brightness. That single property makes building a theme feel mechanical rather than intuitive. Generating a hover state is a small lightness adjustment. Building a dark variant is largely a matter of inverting lightness while keeping hue and chroma, and the result stays coherent instead of turning muddy in the mid tones. Checking contrast becomes something you can reason about from the numbers before you open a checker. There is a real cost, which is that reading a color and picturing it takes practice, and most tooling still speaks hex. I keep a comment with the hex equivalent next to the tokens I edit most, which handles the ninety percent case without giving up the benefits.
03Naming Things by Role, Not by Value
The token layer is split in two, and this is the part that makes theming actually work. The first layer is raw values under semantic names: background, foreground, primary, muted, border, and so on. The second layer maps those to Tailwind's color namespace, which is what generates the utility classes. Components only ever reference the semantic name. Nothing in the component tree knows a hex code or an oklch triple. Dark mode is then a matter of redefining the same semantic names under a dark selector, and every component follows without modification because none of them ever referenced a literal color. This sounds obvious written down and it is very easy to violate under time pressure. I still have a handful of components with literal color utilities in them, gradient decorations mostly, and they are exactly the components that look slightly wrong in one of the two themes. The correlation is not a coincidence. Every literal color is a place where the theme system does not apply, and you find them by switching themes and looking for the thing that did not change.
04What the Migration Broke
Two things caught me, both mundane. Gradient utility names changed, so classes written as bg-gradient-to-r are now bg-linear-to-r, and because an unknown utility class produces no error and no style, the failure is silent. A gradient simply stops existing and the element renders with a flat background that looks intentional enough that you can scroll past it. I found the last of mine by grepping rather than by looking. The second was the custom variant for dark mode, which now needs an explicit declaration in the stylesheet rather than a config key. Miss it and dark mode does nothing at all, which at least fails loudly. The broader lesson is about a class of bug rather than either specific case. In a utility framework, a typo in a class name is not a syntax error. It produces no warning, no console output, and no visual error state, just an element that quietly lacks a style. That is worth an editor extension that validates class names, and it is worth reading a migration diff carefully rather than trusting that a working build means a working page.
Takeaways
Moving configuration into CSS turned design tokens from build-time constants into runtime values, and that unlocked more than the relocation suggested. Define tokens by role rather than by value, use a perceptually uniform color space so the theme math behaves, and keep in mind that a misspelled utility class fails silently.