Skip to content

Tailwind Config in v4: Six Months Without tailwind.config.js

Tailwind Config in v4: Six Months Without tailwind.config.js

Confession: I was about to write this post six months ago and then I realized I hadn’t opened tailwind.config.js in any of my projects in weeks. It wasn’t a deliberate choice. The file was just gone, replaced with a couple of CSS imports and a @theme block at the top of my entry stylesheet. So I waited. Six months in, here’s what actually changed about how I configure Tailwind, and where the new approach still bites me.

Short version for the impatient: the JS config isn’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’ve been putting off the v4 upgrade because you don’t want to rewrite a giant config object, the rewrite is smaller than you think.

The old config file got too big

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 screens object because the project needed an extra breakpoint. Standard stuff for any real app. Here’s a chunk:

// tailwind.config.js: the v3 way
module.exports = {
  content: ["./src/**/*.{tsx,jsx,html}"],
  theme: {
    extend: {
      colors: {
        brand: {
          50:  "#f5f7ff",
          100: "#e8edff",
          500: "#5466ff",
          900: "#1c2660",
        },
        ink: {
          DEFAULT: "#0d1224",
          muted: "#5b6478",
        },
      },
      fontFamily: {
        sans: ['"Inter"', "system-ui", "sans-serif"],
        mono: ['"JetBrains Mono"', "monospace"],
      },
      spacing: {
        "18": "4.5rem",
        "22": "5.5rem",
      },
      screens: {
        "3xl": "1920px",
      },
    },
  },
  plugins: [require("@tailwindcss/forms")],
};

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.

What v4 wants you to do

In v4, the same config moves into CSS, and the entry file looks like this:

/* app.css */
@import "tailwindcss";

@theme {
  --color-brand-50:  #f5f7ff;
  --color-brand-100: #e8edff;
  --color-brand-500: #5466ff;
  --color-brand-900: #1c2660;

  --color-ink:       #0d1224;
  --color-ink-muted: #5b6478;

  --font-sans: "Inter", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  --spacing-18: 4.5rem;
  --spacing-22: 5.5rem;

  --breakpoint-3xl: 1920px;
}

That’s it. No tailwind.config.js. The class names you get out the other end are exactly what you’d expect: bg-brand-500, text-ink-muted, font-mono, pt-18, 3xl:grid-cols-4. The naming convention is --{namespace}-{name}, where the namespace tells Tailwind which class family the token feeds. The full namespace list is in the theme reference, and once you’ve seen it twice you basically don’t need to look it up again.

The thing I didn’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 getComputedStyle(document.documentElement).getPropertyValue('--color-brand-500') and it just works. One source of truth. The annoyance I described above evaporated.

The official Tailwind v4.0 release post walks through the design rationale if you want the framework author’s version of the story. I covered the broader v3 to v4 changes in my Tailwind v4 migration guide, so I won’t rehash all of that here. This post is specifically about config.

The mental model shift

Here’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 @theme are themselves the configuration. The generator reads them, emits utilities that reference them, and they survive into the final stylesheet.

That sounds like a small distinction. It isn’t. It changes what overriding looks like.

In v3, if I wanted the brand color to be different on a marketing page, I’d add a CSS class with a hardcoded hex value, or I’d build a separate Tailwind config for that section, both of which were ugly. In v4, I just do this:

/* marketing.css */
.marketing-section {
  --color-brand-500: #ff6a3d;
}

Every bg-brand-500, text-brand-500, border-brand-500 inside .marketing-section now resolves to the new orange. It cascades like CSS, because it is CSS. I wasn’t expecting how often this would come up. Per-route theming and component variants get noticeably simpler.

Where the JS config still matters

I want to be clear: tailwind.config.js (or .ts) hasn’t been removed. There’s still a @config directive you can drop into your CSS to point Tailwind at one. Two cases where I’ve kept it:

  1. Plugins. First-party plugins like @tailwindcss/forms and @tailwindcss/typography register through the JS config. If I’m using either, I keep a tiny stub config that just exports { plugins: [...] }. The theme stays in CSS.
  2. Programmatic content paths. In one monorepo I work in, the content globs depend on which packages are present. That’s easier to express in JS than CSS. Same trick: tiny config, theme in CSS.

Most projects don’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 @tailwindcss/typography, and when I checked, the v4 prose styles were good enough for what I needed. Dropped the plugin entirely.

The Tailwind team has Vite installation docs showing what the minimum project setup looks like, and “minimum” really is minimum: a single @import "tailwindcss" in your CSS, the plugin in vite.config, done.

Where the new way trips me up

I’d be lying if I said this is all upside. A few things have caught me.

Variable naming is fussy. If I write --color-brand instead of --color-brand-500, Tailwind treats it as a single value, which means I get bg-brand but not bg-brand-500. If I want a scale, I have to enumerate the steps. That’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.

I lost a small bit of programmability. In v3 I had a config that imported a colors.json file and looped over it with a function. I can’t quite do that in CSS yet, without preprocessing. For one project I solved it by writing a tiny script that generates the @theme 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.

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’t see your custom theme. I lost an hour to this on a Next.js project until I realized my app.css wasn’t in the path the language server was scanning. Fix was a one-line config change. Still annoyed about the hour.

My current setup

Here’s what a fresh project looks like for me now. One CSS file, no JS config, Vite handling the rest:

/* src/styles/app.css */
@import "tailwindcss";

@theme {
  /* colors */
  --color-brand-500: #5466ff;
  --color-brand-900: #1c2660;
  --color-ink: #0d1224;
  --color-ink-muted: #5b6478;

  /* type */
  --font-sans: "Inter", system-ui, sans-serif;

  /* motion */
  --animate-fade-in: fade-in 200ms ease-out;
  @keyframes fade-in {
    from { opacity: 0; transform: translateY(2px); }
    to   { opacity: 1; transform: translateY(0);   }
  }
}

/* component-scoped tweaks live as plain CSS, no Tailwind plugin needed */
.prose-tight :is(h2, h3) { letter-spacing: -0.01em; }

Build config is exactly:

// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});

No tailwind.config.js. No postcss.config.js. The CSS file is the config. I covered a related v4 win in my piece on container queries, which also slot into the same CSS-first model.

For client work where the design system has a lot of tokens, I’ll often build the @theme block from a JSON source of truth. I keep a generator script in the repo and run it as a pre-build step. That’s the kind of small tooling I get into more on my work page if you’re curious how I structure projects like this.

Try this on Monday

If you’re on v3 and you’ve been waiting, here’s the smallest experiment I can suggest. Pick one project. Open tailwind.config.js. Identify the things you’ve actually customized, usually colors, fonts, and a handful of spacing values. Move just those into a @theme 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’ve shrunk your config without committing to the full migration in one go. If you need a longer view of v4’s other changes before you commit, my migration guide is the right next read.

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’t. It’s less code, and the design tokens have one source of truth instead of two. I haven’t missed tailwind.config.js once.