TailwindCSS v4+ Custom Theme Styling

This is not an intuitive thing to understand unless you’re already deep into TailwindCSS. Or, if you are me. So, if you somehow stumbled onto this post while trying to figure things out — welcome to the confusion. I hope I can help make it a little clearer.

What I Was Trying to Do

I needed two themes that I could toggle dynamically, for simplicity let’s call them light mode and dark mode. The app I’m working on is a forked version of Jumpstart Pro. But even if that’s not your setup, stick with me. I think the fix I landed on is universal, just giving a little context.

How to Set Up Custom Themes

First, in your app/tailwind directory, create a new folder called themes if you don’t already have one. Inside that folder, add CSS files for each theme you want. For example, I created:

  • light.css
  • dark.css
  • default.css (this one’s important)

Then, include those files in your main Tailwind entry point, probably app/assets/tailwind/application.css — like this:

@import "./themes/light.css" layer(theme);
@import "./themes/dark.css" layer(theme);
@import "./themes/default.css" layer(theme);

Two Ways to Define Theme Variables

In Tailwind v4, you have two main ways to define CSS variables for your themes.

Option 1: The New @theme Directive

This is the new hotness in Tailwind v4+:

@theme {
  --color-green-25: #EEF6F1;
  --color-green-50: #DDEEE3;
  /* ... */
}

Each @theme block defines variables scoped to that theme. Tailwind picks this up during build and keeps track of those values, but more on why that matters in a second.

Option 2: Regular CSS (:root, .light, etc.)

You might also see styles defined like this:

:root,
.light {
  --color-gray-50: var(--color-neutral-50);
  --color-gray-100: var(--color-neutral-100);
  /* ... */
}

This is valid CSS, and it works in the browser. But here’s the kicker: Tailwind doesn’t really see these during its build process unless you specifically tell it to. That’s because Tailwind doesn’t ship with everything. It looks through your code ahead of time and only keeps the classes that it finds. Think of it like packing for a trip: if Tailwind doesn’t see you mention a specific class in your files, or your “to-pack” list (if we are keeping this analogy), it assumes it’s not needed and leaves it behind.

Why That Matters

Tailwind only includes the utility classes it finds in your templates, because it’s trying to keep your CSS bundle as small as possible. If it doesn’t see a class being used (or a variable being referenced in a @theme or @layer), it might purge it.

That’s why the @theme directive is a game-changer: it tells Tailwind, “Hey, keep these around, they’re important.”

My Initial Confusion

This tripped me up because I didn’t realize what was happening in the tailwind build process. I was defining variables like this:

:root, .light {
  --color-primary: red;
  /* ... */
}

Then I used bg-primary in my HTML and wondered why it wasn’t working. The class was there in the inspector, but it showed a tooltip saying it was undefined.

Turns out: Tailwind didn’t register it, because I didn’t define it using @theme or inside a recognized layer like @layer theme.

The Fix That Finally Worked

Once I moved my variables into @theme blocks, in my case it was creating and defining within default.css, everything clicked.

In my default.css file, I created the initial variables like this:

@theme {
  --color-primary: var(--bg-primary);
  /* ... */
}

I could then define variables like this in my themes (light.css, dark.css) that override the initial variables that I defined in the @theme directory (default.css). Like so:

:root, .light {
  --color-primary-600: rgba(35, 100, 139, 1);
  --bg-primary: var(--color-primary-600);
  /* ... */
}

:root, .dark {
  --color-primary-400: rgba(25, 10, 139, 1);
  --bg-primary: var(--color-primary-400);
  /* ... */
}

Now, when I switch themes dynamically (e.g., toggling class="light" or class="dark" on <html>), the variables change, and the styles update: no weird missing classes, no broken UI!

Final Thoughts

You can toss in whatever tokens or utilities you want, things like bg-primary, btn-primary, border-secondary, etc., just make sure any design tokens (like --color-primary) live inside an @theme block so Tailwind actually knows to generate the corresponding classes.

Classes and styles inside @layer blocks are also picked up, but if you’ve got tokens just hanging out in selectors like :root or .light without wrapping them in a @theme first, Tailwind’s gonna miss them. Tailwind can’t pack what it can’t see so write your styles where it knows to look.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.

Published on August 21, 2025