When you think about implementing a theme system, the first instinct is to add conditional classes to every component. But what if you could swap the entire app’s color palette by changing a single CSS block — without touching any HTML? In Tailwind v4, that’s exactly what you can do.
How Tailwind v4 Compiles Utility Classes
Tailwind v4 compiles utility classes as CSS variable references:
/* CSS generated by Tailwind v4 */
.bg-emerald-700 {
background-color: var(--color-emerald-700);
}
.text-emerald-600 {
color: var(--color-emerald-600);
}
.border-emerald-500 {
border-color: var(--color-emerald-500);
}
bg-emerald-700 doesn’t hard-code #047857 — it references var(--color-emerald-700). Change that variable and every element using bg-emerald-700 updates instantly.
Theme Architecture
If your app’s primary color scale is emerald, themes can be implemented by replacing the entire --color-emerald-* variable set:
/* tokens.css — leave default emerald variables untouched, just append theme blocks */
[data-app-theme="wimbledon"] {
--color-emerald-50: #f5f0ff;
--color-emerald-100: #ede0ff;
--color-emerald-200: #dcc8ff;
--color-emerald-300: #c4a3ff;
--color-emerald-400: #a87eff;
--color-emerald-500: #7B2082;
--color-emerald-600: #6a1a73;
--color-emerald-700: #522398;
--color-emerald-800: #3d1870;
--color-emerald-900: #2c1050;
--color-primary: #522398;
--color-accent: #00653A;
--shadow-focus: 0 0 0 3px rgba(82, 35, 152, 0.2);
}
[data-app-theme="roland-garros"] {
--color-emerald-500: #C95917;
--color-emerald-700: #963d08;
--color-primary: #C95917;
--color-accent: #02503B;
--shadow-focus: 0 0 0 3px rgba(201, 89, 23, 0.2);
}
[data-app-theme="us-open"] {
--color-emerald-500: #003DA5;
--color-emerald-700: #002370;
--color-primary: #003DA5;
--color-accent: #FFB300;
--shadow-focus: 0 0 0 3px rgba(0, 61, 165, 0.2);
}
[data-app-theme="australian-open"] {
--color-emerald-500: #0085CA;
--color-emerald-700: #005a8c;
--color-primary: #0085CA;
--color-accent: #84BD00;
--shadow-focus: 0 0 0 3px rgba(0, 133, 202, 0.2);
}
Add data-app-theme="wimbledon" to <html> and every bg-emerald-700, text-emerald-500, border-emerald-600 in the entire app turns Wimbledon purple. Zero HTML changes required.
Color Choices
The colors are sourced from each tournament’s official brand identity:
| Theme | Primary | Accent | Source |
|---|---|---|---|
| Wimbledon | #522398 (Pantone 268C) | #00653A (Pantone 349C) | All England Club official purple/green |
| Roland Garros | #C95917 | #02503B | Red clay court + forest green |
| US Open | #003DA5 (USTA Blue) | #FFB300 | USTA official blue + gold |
| Australian Open | #0085CA (Process Blue) | #84BD00 | Official blue + lime |
The Stimulus Controller
A single Stimulus controller handles selection, persistence, and application:
// app/javascript/controllers/theme_controller.js
import { Controller } from "@hotwired/stimulus"
const STORAGE_KEY = "app-theme"
export default class extends Controller {
connect() {
const saved = localStorage.getItem(STORAGE_KEY) || "default"
this._apply(saved)
}
select(event) {
const theme = event.currentTarget.dataset.themeValue
localStorage.setItem(STORAGE_KEY, theme)
this._apply(theme)
}
_apply(theme) {
if (theme === "default") {
document.documentElement.removeAttribute("data-app-theme")
} else {
document.documentElement.setAttribute("data-app-theme", theme)
}
// Update active button state
this.element.querySelectorAll("[data-theme-value]").forEach(el => {
const isActive = el.dataset.themeValue === theme
el.setAttribute("aria-pressed", isActive ? "true" : "false")
el.classList.toggle("ring-2", isActive)
el.classList.toggle("ring-offset-2", isActive)
})
}
}
Mount it on <body> so theme buttons can appear anywhere in the app, not just on the settings page:
<body data-controller="theme">
...
</body>
Preventing FOUC
The Stimulus controller runs after JavaScript parses. During that gap, the page flashes in the default theme — FOUC (Flash of Unstyled Content).
Fix: add an inline script inside <head> that applies the theme synchronously before CSS loads.
<!-- Top of <head> in layouts/application.html.erb -->
<script>
try {
var t = localStorage.getItem('app-theme');
if (t && t !== 'default') {
document.documentElement.setAttribute('data-app-theme', t);
}
} catch(e) {}
</script>
The try/catch guards against environments where localStorage is blocked (e.g., private browsing with strict settings). Under 10 lines of code solving the same problem dark mode implementations solve in exactly the same way.
Theme Picker UI
Structure of a single theme button:
<button
type="button"
data-action="theme#select"
data-theme-value="wimbledon"
class="flex flex-col items-center gap-2 p-3 rounded-xl border-2 border-transparent
hover:border-emerald-300 transition-all duration-150 cursor-pointer"
aria-pressed="false"
>
<!-- Two-tone color swatch -->
<div class="w-16 h-4 rounded-full overflow-hidden flex">
<div class="flex-1" style="background: #522398;"></div>
<div class="flex-1" style="background: #00653A;"></div>
</div>
<!-- Mini app preview -->
<div class="w-12 h-16 rounded-lg overflow-hidden border border-gray-200"
style="background: #f5f0ff;">
<div class="h-3 w-full" style="background: #522398;"></div>
<div class="p-1 space-y-1">
<div class="h-1.5 rounded" style="background: #7B2082; opacity: 0.7;"></div>
<div class="h-1.5 rounded w-3/4" style="background: #7B2082; opacity: 0.4;"></div>
</div>
</div>
<span class="text-xs font-medium text-gray-700">Wimbledon</span>
</button>
ring-2 ring-offset-2 is toggled by _apply() for the active theme. aria-pressed updates alongside it for accessibility.
Why This Approach Works Well
- No existing code changes needed: No need to hunt down every
bg-emerald-700across components. - Zero runtime overhead: It’s one CSS variable cascading — not JavaScript swapping classes.
- Composable with dark mode:
[data-app-theme="wimbledon"].dark {}or.dark [data-app-theme="wimbledon"] {}— cross-apply as needed. - Trivially extensible: Adding a new theme is one CSS block.
The key enabler is Tailwind v4’s shift from hard-coded color values to CSS variable references in compiled output. This change dramatically lowers the cost of building theme systems.

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.