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:

ThemePrimaryAccentSource
Wimbledon#522398 (Pantone 268C)#00653A (Pantone 349C)All England Club official purple/green
Roland Garros#C95917#02503BRed clay court + forest green
US Open#003DA5 (USTA Blue)#FFB300USTA official blue + gold
Australian Open#0085CA (Process Blue)#84BD00Official 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

  1. No existing code changes needed: No need to hunt down every bg-emerald-700 across components.
  2. Zero runtime overhead: It’s one CSS variable cascading — not JavaScript swapping classes.
  3. Composable with dark mode: [data-app-theme="wimbledon"].dark {} or .dark [data-app-theme="wimbledon"] {} — cross-apply as needed.
  4. 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.