Theming Tailwind CSS v4 without a config file
My whole design system lives in one CSS file: OKLCH color tokens, an @theme inline block, keyframes and plugins, with no tailwind.config at all. How config-less Tailwind v4 works in practice.
This portfolio has no tailwind.config file. Not a trimmed one, not an empty one, none at all. The entire design system lives in a single CSS file, globals.css, and after building the site I am convinced this is the better default for Tailwind v4. Here is how it actually works, straight from the file that ships.
Configuration moved from JavaScript to CSS
In v3, the theme was a JavaScript object exported from tailwind.config.js, and content globs told the compiler where to look. In v4 that whole surface moves into CSS. The stylesheet opens by importing the framework and its companions, then loading plugins with an at-rule rather than an array entry:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";The typography plugin is what renders these very paragraphs. In v3 it would have been a require call inside a plugins array. In v4 it is one @plugin line. There is no JavaScript module to import, no object to merge, and nothing to keep in sync with the stylesheet. The config is the stylesheet.
The @theme inline block
The heart of the system is a single @theme inline block. It maps CSS custom properties to the utility names Tailwind generates. When I declare --color-background, I get bg-background, text-background and every other color utility for free. When I declare --font-display, I get the font-display class. The mapping is mechanical and complete:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-display: var(--font-display);
--color-violet: var(--violet);
--color-indigo: var(--indigo);
}The radius scale is where the CSS-first approach starts to feel like a real language rather than a config format. I define one --radius value and derive the rest with calc, so the whole scale moves together if I change the base:
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);Animations live in the same block, and this is the part I did not expect to like. Named animation tokens become animate-* utilities, and their keyframes are declared right next to them, inside the theme:
--animate-marquee: marquee var(--duration) infinite linear;
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}The animate-marquee class and the keyframes it depends on sit within a few lines of each other. Nothing about that animation is defined in two separate files that have to agree with one another.
OKLCH, and why it is pleasant
Every color in the system is an OKLCH value. The site is dark only, so layout.tsx hard-codes a dark class on the html element, which means the real palette lives under the .dark selector. The :root block holds the light values that the site never actually shows. OKLCH is the reason the palette was easy to build. The first number is perceptual lightness, so 0.13 reads as dark and 0.94 reads as near white in a way that matches what your eye reports. The last number is a single hue axis, so shifting the whole theme warmer or cooler is one number, not a hex-code guessing game.
The brand accent is two stops on that axis. Violet sits at oklch(0.68 0.19 293) and indigo at oklch(0.62 0.21 275), and the gradient between them is the visual signature reused across buttons, the logo mark and headline highlights. Because both stops share the same color model, the gradient stays even and never muddies through a gray dead zone the way interpolating two hex colors sometimes does.
OKLCH also pays off at runtime through color-mix. Components build glows and spotlights by mixing a token with transparent in the same color space, so a highlight is derived from the brand token rather than hardcoded. The text selection color in the base layer is exactly this:
::selection {
background-color: color-mix(in oklch, var(--violet) 30%, transparent);
color: var(--foreground);
}The dark variant and semantic tokens
One line teaches Tailwind what dark means for this project:
@custom-variant dark (&:is(.dark *));From there, components never name a raw color. They consume semantic tokens: bg-background, text-muted-foreground, border-border, bg-card and their siblings. The token points at a custom property, the property resolves under .dark, and the component stays honest. If I ever wanted to reintroduce a light mode, the wiring is already there. The values under :root are unused today but not wrong.
What else lives in the same file
Because there is only one file, it also carries the base layer and the accessibility guards. The base layer sets the body background and text color, applies the sans font to html, and turns on smooth scrolling with a scroll-padding-top of 6rem so anchored sections clear the sticky nav. It defines a keyboard focus ring on :focus-visible, so the ring appears for keyboard users and not on every mouse click. And a global prefers-reduced-motion guard collapses animation and transition durations to near zero for anyone whose operating system asks for calm, catching the CSS-driven animations like marquee that Motion cannot gate on its own. All of that is in the same file as the theme, which is the point.
The honest tradeoffs
Config-less is not free of costs, and I would rather name them than pretend they do not exist.
The obvious loss is JavaScript. A tailwind.config file can compute values in code, loop to generate a scale, or pull tokens from another module. Everything here is declarative CSS instead, so the calc-derived radius scale is about as clever as it gets. In exchange, the whole theme is one file I can read top to bottom in a minute, and a diff on it reviews trivially because there is no indirection to trace. The other cost is tooling. Some plugins and editor integrations still expect a config file to exist and get quieter or less capable without one. And when a design needs a value that is not in the scale, I reach for an arbitrary value in the markup, which is fine as a rare escape hatch but would be a smell if it became the norm.
Takeaways
A few things worth carrying forward:
- Let the stylesheet be the config. One @theme inline block and a few @import and @plugin lines replace the entire JavaScript config surface.
- Derive scales instead of listing them. One --radius plus calc keeps the whole radius scale in proportion.
- Work in OKLCH. Perceptual lightness and a single hue axis make a palette and a brand gradient far easier to reason about than hex.
- Reach for color-mix for glows and highlights so they derive from your tokens rather than drifting into hardcoded one-offs.
- Keep components on semantic tokens. Name the role, not the color, and the theme stays the single place that decides how things look.
One file holds the colors, the scales, the keyframes, the plugins and the accessibility guards. For a site this size, that is not a limitation. It is the feature.