{"id":166,"date":"2026-04-29T05:03:52","date_gmt":"2026-04-29T05:03:52","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tailwind-config-v4-six-months-without-config-js\/"},"modified":"2026-04-29T05:03:52","modified_gmt":"2026-04-29T05:03:52","slug":"tailwind-config-v4-six-months-without-config-js","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tailwind-config-v4-six-months-without-config-js\/","title":{"rendered":"Tailwind Config in v4: Six Months Without tailwind.config.js"},"content":{"rendered":"<p>Confession: I was about to write this post six months ago and then I realized I hadn&rsquo;t opened <code>tailwind.config.js<\/code> in any of my projects in weeks. It wasn&rsquo;t a deliberate choice. The file was just gone, replaced with a couple of CSS imports and a <code>@theme<\/code> block at the top of my entry stylesheet. So I waited. Six months in, here&rsquo;s what actually changed about how I configure Tailwind, and where the new approach still bites me.<\/p>\n<p>Short version for the impatient: the JS config isn&rsquo;t dead, but you stop reaching for it. Theme tokens, custom colors, font stacks, breakpoints, animations: all of that can live in CSS now. The config file becomes a place for plugins and content paths, and even those are mostly automatic in Vite projects. If you&rsquo;ve been putting off the v4 upgrade because you don&rsquo;t want to rewrite a giant config object, the rewrite is smaller than you think.<\/p>\n<h2 id=\"the-old-config-file-got-too-big\">The old config file got too big<\/h2>\n<p>I went back and looked at one of my v3 projects. The config file was 217 lines. Most of it was theme extension: a custom color palette, three font families, a half-dozen extra spacing values, two animation keyframes, an explicit <code>screens<\/code> object because the project needed an extra breakpoint. Standard stuff for any real app. Here&rsquo;s a chunk:<\/p>\n<pre><code class=\"language-js\">\/\/ tailwind.config.js: the v3 way\nmodule.exports = {\n  content: [&quot;.\/src\/**\/*.{tsx,jsx,html}&quot;],\n  theme: {\n    extend: {\n      colors: {\n        brand: {\n          50:  &quot;#f5f7ff&quot;,\n          100: &quot;#e8edff&quot;,\n          500: &quot;#5466ff&quot;,\n          900: &quot;#1c2660&quot;,\n        },\n        ink: {\n          DEFAULT: &quot;#0d1224&quot;,\n          muted: &quot;#5b6478&quot;,\n        },\n      },\n      fontFamily: {\n        sans: ['&quot;Inter&quot;', &quot;system-ui&quot;, &quot;sans-serif&quot;],\n        mono: ['&quot;JetBrains Mono&quot;', &quot;monospace&quot;],\n      },\n      spacing: {\n        &quot;18&quot;: &quot;4.5rem&quot;,\n        &quot;22&quot;: &quot;5.5rem&quot;,\n      },\n      screens: {\n        &quot;3xl&quot;: &quot;1920px&quot;,\n      },\n    },\n  },\n  plugins: [require(&quot;@tailwindcss\/forms&quot;)],\n};\n<\/code><\/pre>\n<p>This worked. It always worked. But every time I needed to use a brand color outside of Tailwind, in a chart library or a styled-component fallback or an SVG fill, I had to either remember the hex value or import it from the JS module and stringify it. Two sources of truth, basically. Bad.<\/p>\n<h2 id=\"what-v4-wants-you-to-do\">What v4 wants you to do<\/h2>\n<p>In v4, the same config moves into CSS, and the entry file looks like this:<\/p>\n<pre><code class=\"language-css\">\/* app.css *\/\n@import &quot;tailwindcss&quot;;\n\n@theme {\n  --color-brand-50:  #f5f7ff;\n  --color-brand-100: #e8edff;\n  --color-brand-500: #5466ff;\n  --color-brand-900: #1c2660;\n\n  --color-ink:       #0d1224;\n  --color-ink-muted: #5b6478;\n\n  --font-sans: &quot;Inter&quot;, system-ui, sans-serif;\n  --font-mono: &quot;JetBrains Mono&quot;, monospace;\n\n  --spacing-18: 4.5rem;\n  --spacing-22: 5.5rem;\n\n  --breakpoint-3xl: 1920px;\n}\n<\/code><\/pre>\n<p>That&rsquo;s it. No <code>tailwind.config.js<\/code>. The class names you get out the other end are exactly what you&rsquo;d expect: <code>bg-brand-500<\/code>, <code>text-ink-muted<\/code>, <code>font-mono<\/code>, <code>pt-18<\/code>, <code>3xl:grid-cols-4<\/code>. The naming convention is <code>--{namespace}-{name}<\/code>, where the namespace tells Tailwind which class family the token feeds. The full namespace list is in the <a href=\"https:\/\/tailwindcss.com\/docs\/theme\" rel=\"nofollow noopener\" target=\"_blank\">theme reference<\/a>, and once you&rsquo;ve seen it twice you basically don&rsquo;t need to look it up again.<\/p>\n<p>The thing I didn&rsquo;t expect: those custom properties stay as real CSS variables in the output. So when I need the brand color in a chart config, I do <code>getComputedStyle(document.documentElement).getPropertyValue('--color-brand-500')<\/code> and it just works. One source of truth. The annoyance I described above evaporated.<\/p>\n<p>The official <a href=\"https:\/\/tailwindcss.com\/blog\/tailwindcss-v4\" rel=\"nofollow noopener\" target=\"_blank\">Tailwind v4.0 release post<\/a> walks through the design rationale if you want the framework author&rsquo;s version of the story. I covered the broader v3 to v4 changes in my <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-css-v4-migration-guide-what-actually-changed\/\" rel=\"noopener\">Tailwind v4 migration guide<\/a>, so I won&rsquo;t rehash all of that here. This post is specifically about config.<\/p>\n<h2 id=\"the-mental-model-shift\">The mental model shift<\/h2>\n<p>Here&rsquo;s the part that took me longest to internalize. In v3, Tailwind generated a big set of utility classes from your JS config at build time. The config was an input to a code generator. In v4, the CSS variables you define inside <code>@theme<\/code> are themselves the configuration. The generator reads them, emits utilities that reference them, and they survive into the final stylesheet.<\/p>\n<p>That sounds like a small distinction. It isn&rsquo;t. It changes what overriding looks like.<\/p>\n<p>In v3, if I wanted the brand color to be different on a marketing page, I&rsquo;d add a CSS class with a hardcoded hex value, or I&rsquo;d build a separate Tailwind config for that section, both of which were ugly. In v4, I just do this:<\/p>\n<pre><code class=\"language-css\">\/* marketing.css *\/\n.marketing-section {\n  --color-brand-500: #ff6a3d;\n}\n<\/code><\/pre>\n<p>Every <code>bg-brand-500<\/code>, <code>text-brand-500<\/code>, <code>border-brand-500<\/code> inside <code>.marketing-section<\/code> now resolves to the new orange. It cascades like CSS, because it is CSS. I wasn&rsquo;t expecting how often this would come up. Per-route theming and component variants get noticeably simpler.<\/p>\n<h2 id=\"where-the-js-config-still-matters\">Where the JS config still matters<\/h2>\n<p>I want to be clear: <code>tailwind.config.js<\/code> (or <code>.ts<\/code>) hasn&rsquo;t been removed. There&rsquo;s still a <code>@config<\/code> directive you can drop into your CSS to point Tailwind at one. Two cases where I&rsquo;ve kept it:<\/p>\n<ol>\n<li><strong>Plugins.<\/strong> First-party plugins like <code>@tailwindcss\/forms<\/code> and <code>@tailwindcss\/typography<\/code> register through the JS config. If I&rsquo;m using either, I keep a tiny stub config that just exports <code>{ plugins: [...] }<\/code>. The theme stays in CSS.<\/li>\n<li><strong>Programmatic content paths.<\/strong> In one monorepo I work in, the content globs depend on which packages are present. That&rsquo;s easier to express in JS than CSS. Same trick: tiny config, theme in CSS.<\/li>\n<\/ol>\n<p>Most projects don&rsquo;t need either. The Vite plugin auto-detects content paths, and a lot of plugin functionality has migrated into core or into CSS-native utilities. I had a chart project that used <code>@tailwindcss\/typography<\/code>, and when I checked, the v4 prose styles were good enough for what I needed. Dropped the plugin entirely.<\/p>\n<p>The Tailwind team has <a href=\"https:\/\/tailwindcss.com\/docs\/installation\/using-vite\" rel=\"nofollow noopener\" target=\"_blank\">Vite installation docs<\/a> showing what the minimum project setup looks like, and &ldquo;minimum&rdquo; really is minimum: a single <code>@import \"tailwindcss\"<\/code> in your CSS, the plugin in <code>vite.config<\/code>, done.<\/p>\n<h2 id=\"where-the-new-way-trips-me-up\">Where the new way trips me up<\/h2>\n<p>I&rsquo;d be lying if I said this is all upside. A few things have caught me.<\/p>\n<p>Variable naming is fussy. If I write <code>--color-brand<\/code> instead of <code>--color-brand-500<\/code>, Tailwind treats it as a single value, which means I get <code>bg-brand<\/code> but not <code>bg-brand-500<\/code>. If I want a scale, I have to enumerate the steps. That&rsquo;s correct behavior. Tailwind has no way to know I wanted a scale rather than a single color. But it caught me twice before I internalized it.<\/p>\n<p>I lost a small bit of programmability. In v3 I had a config that imported a <code>colors.json<\/code> file and looped over it with a function. I can&rsquo;t quite do that in CSS yet, without preprocessing. For one project I solved it by writing a tiny script that generates the <code>@theme<\/code> block from the JSON at build time. Works fine. Adds a step. If your tokens come from a design system source of truth, expect to write that script.<\/p>\n<p>Editor support is still uneven. The Tailwind IntelliSense extension caught up fast, but if you have a CSS-in-JS layer or a non-standard build setup, you can hit a window where autocomplete doesn&rsquo;t see your custom theme. I lost an hour to this on a Next.js project until I realized my <code>app.css<\/code> wasn&rsquo;t in the path the language server was scanning. Fix was a one-line config change. Still annoyed about the hour.<\/p>\n<h2 id=\"my-current-setup\">My current setup<\/h2>\n<p>Here&rsquo;s what a fresh project looks like for me now. One CSS file, no JS config, Vite handling the rest:<\/p>\n<pre><code class=\"language-css\">\/* src\/styles\/app.css *\/\n@import &quot;tailwindcss&quot;;\n\n@theme {\n  \/* colors *\/\n  --color-brand-500: #5466ff;\n  --color-brand-900: #1c2660;\n  --color-ink: #0d1224;\n  --color-ink-muted: #5b6478;\n\n  \/* type *\/\n  --font-sans: &quot;Inter&quot;, system-ui, sans-serif;\n\n  \/* motion *\/\n  --animate-fade-in: fade-in 200ms ease-out;\n  @keyframes fade-in {\n    from { opacity: 0; transform: translateY(2px); }\n    to   { opacity: 1; transform: translateY(0);   }\n  }\n}\n\n\/* component-scoped tweaks live as plain CSS, no Tailwind plugin needed *\/\n.prose-tight :is(h2, h3) { letter-spacing: -0.01em; }\n<\/code><\/pre>\n<p>Build config is exactly:<\/p>\n<pre><code class=\"language-ts\">\/\/ vite.config.ts\nimport { defineConfig } from &quot;vite&quot;;\nimport tailwindcss from &quot;@tailwindcss\/vite&quot;;\n\nexport default defineConfig({\n  plugins: [tailwindcss()],\n});\n<\/code><\/pre>\n<p>No <code>tailwind.config.js<\/code>. No <code>postcss.config.js<\/code>. The CSS file is the config. I covered a related v4 win in my piece on <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-container-queries-i-finally-get-it\/\" rel=\"noopener\">container queries<\/a>, which also slot into the same CSS-first model.<\/p>\n<p>For client work where the design system has a lot of tokens, I&rsquo;ll often build the <code>@theme<\/code> block from a JSON source of truth. I keep a generator script in the repo and run it as a pre-build step. That&rsquo;s the kind of small tooling I get into more on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work page<\/a> if you&rsquo;re curious how I structure projects like this.<\/p>\n<h2 id=\"try-this-on-monday\">Try this on Monday<\/h2>\n<p>If you&rsquo;re on v3 and you&rsquo;ve been waiting, here&rsquo;s the smallest experiment I can suggest. Pick one project. Open <code>tailwind.config.js<\/code>. Identify the things you&rsquo;ve actually customized, usually colors, fonts, and a handful of spacing values. Move just those into a <code>@theme<\/code> block in your main CSS file using the v4 namespace conventions. Remove them from the JS file. Run your build. Most of the time it just works, and you&rsquo;ve shrunk your config without committing to the full migration in one go. If you need a longer view of v4&rsquo;s other changes before you commit, my <a href=\"https:\/\/abrarqasim.com\/blog\/tailwind-css-v4-migration-guide-what-actually-changed\/\" rel=\"noopener\">migration guide<\/a> is the right next read.<\/p>\n<p>The thing I was wrong about, going in: I assumed CSS-first config would feel like a sidegrade, a stylistic preference one way or the other. After six months, it doesn&rsquo;t. It&rsquo;s less code, and the design tokens have one source of truth instead of two. I haven&rsquo;t missed <code>tailwind.config.js<\/code> once.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I deleted tailwind.config.js six months ago and moved every theme variable into CSS. Here&#8217;s what changed about my workflow, and where the new approach still bites me.<\/p>\n","protected":false},"author":2,"featured_media":165,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I deleted tailwind.config.js six months ago and moved every theme variable into CSS. Here's what changed about my workflow, and where the new approach still bites me.","rank_math_focus_keyword":"tailwind config","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[137,35],"tags":[37,38,139,134,39],"class_list":["post-166","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-css","category-web-development","tag-css","tag-frontend","tag-tailwind","tag-tailwind-v4","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/166","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=166"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/166\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/165"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=166"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=166"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=166"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}