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