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

The architectural shift that makes this entire pattern possible is subtle but profound. Tailwind v4 compiles utility classes as CSS variable references rather than hard-coded values:

/* 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);
}

In Tailwind v3, bg-emerald-700 compiled to background-color: #047857 — a hard-coded hex value baked into the stylesheet. In v4, it compiles to background-color: var(--color-emerald-700) — a CSS custom property reference. The actual color value is stored in a variable declared on :root.

This distinction is everything. CSS custom properties cascade and can be overridden by any selector with sufficient specificity. If you override --color-emerald-700 inside a scoped selector, every element inside that scope using bg-emerald-700 will pick up the new value — instantly, with no JavaScript class manipulation, no re-renders, no repaints beyond what CSS already triggers.

This is the same mechanism that powers dark mode in most modern implementations: a [data-theme="dark"] or .dark selector on <html> overrides color variables, and the entire subtree responds. Our multi-theme system is the same idea, generalized to any number of color palettes.


Theme Architecture

If your app’s primary color scale is emerald, themes can be implemented by replacing the entire --color-emerald-* variable set within attribute-scoped selectors:

/* 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.

Notice the intentional asymmetry between the Wimbledon block and the others: Wimbledon overrides the full 50–900 scale because it shifts the entire color family (green to purple), while Roland Garros, US Open, and Australian Open only override the key stop points (500 and 700). The minimal override approach keeps the CSS lean — you only need to redeclare variables that actually differ from the defaults.


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

Using brand-accurate Pantone-derived values matters because the theming only works if the CSS variable overrides maintain sufficient contrast ratios. The --shadow-focus variable per theme is critical for accessibility — keyboard focus rings must be clearly visible against the new primary color, and a generic blue glow would clash badly with the Wimbledon purple or Roland Garros orange.


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)
    })
  }
}

The connect() lifecycle callback re-applies the stored theme on every page load. In a Hotwire (Turbo Drive) app, this is called once on initial load and again on any full Turbo navigation where the controller element is reconnected. For partial Turbo Frame updates, the <html> attribute is already set, so no re-application is needed.

Mount it on <body> so theme buttons can appear anywhere in the app, not just on the settings page:

<body data-controller="theme">
  ...
</body>

The controller’s select() action is attached via data-action="theme#select" on each button. Any number of theme picker UIs can exist in the DOM simultaneously — they all share the same controller instance on <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). On a slow connection, or a device where script parsing is deferred, this gap can be noticeable: the page renders green (the default emerald palette), then snaps to purple when the controller activates. Visually jarring.

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>

This script runs synchronously during HTML parsing, before any external CSS or JavaScript loads. By the time the browser constructs the CSSOM and paints the first frame, data-app-theme is already set on <html>. The CSS attribute selectors match immediately, and the user sees the correct theme from the very first paint.

The try/catch guards against environments where localStorage is blocked — private browsing with strict settings, certain browser extensions, or embedded webviews. The catch block is intentionally empty: if storage is unavailable, the default theme is fine.

This same inline-script-in-head technique is how virtually all dark mode implementations prevent flash. We are reusing an established, battle-tested pattern.


Theme Picker UI

The theme picker renders a grid of buttons, each showing a color swatch and a miniature app preview. 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>

The swatch and preview use inline style attributes with hard-coded hex values rather than Tailwind classes. This is intentional: the preview must always show the target theme’s colors regardless of which theme is currently active. If you used bg-emerald-700 on the swatch, it would shift color when the active theme changes — breaking the preview’s purpose as a static reference.

ring-2 ring-offset-2 is toggled by _apply() for the active theme. aria-pressed updates alongside it for accessibility. A screen reader will announce “Wimbledon, pressed” for the active theme button, which is the correct semantic signal.


Composability with Dark Mode

The theme system composes naturally with dark mode. The two systems use orthogonal selectors:

/* Theme overrides primary scale */
[data-app-theme="wimbledon"] {
  --color-emerald-700: #522398;
}

/* Dark mode overrides background and text tones */
.dark {
  --color-gray-50: #1a1a1a;
  --color-gray-900: #f5f5f5;
}

/* Combined: dark Wimbledon theme */
.dark [data-app-theme="wimbledon"] {
  --color-emerald-700: #6b35b0; /* slightly lighter for dark backgrounds */
}

This gives you a matrix of themes × modes. The only CSS you need to write is the intersection blocks that require distinct values — most of the time, the default theme override and the dark mode override are sufficient and you get the combined state for free.


Why This Approach Works Well

No existing code changes needed. There is no need to audit every component for bg-emerald-700 references and wrap them in conditional logic. The scope of change is confined entirely to the CSS token file.

Zero runtime overhead. Switching themes sets one attribute on <html>. The browser handles the rest through its built-in CSS cascade. There is no JavaScript walking the DOM, no class manipulation, no virtual DOM reconciliation.

Trivially extensible. Adding a new theme is one CSS block. Removing a theme is deleting that block. The theme count does not increase code complexity anywhere else in the application.

Testable and predictable. Every theme is a deterministic set of variable values. You can test visual correctness by setting document.documentElement.setAttribute('data-app-theme', 'wimbledon') in any browser or test environment and inspecting computed styles.

The key enabler is Tailwind v4’s architectural shift from hard-coded color values to CSS variable references in compiled output. This change dramatically lowers the cost of building theme systems, and it happened as a consequence of Tailwind v4’s new CSS-first configuration model — not as a deliberate theming feature. We are taking advantage of how the compiler works, not a dedicated theming API. That makes the pattern stable across minor versions.


Key Takeaways

  • Tailwind v4 compiles bg-emerald-700 to var(--color-emerald-700), not a hard-coded hex. This is the foundational change that makes the pattern possible.
  • Overriding CSS custom properties inside an attribute selector ([data-app-theme="..."]) on <html> propagates to every descendant element — the entire app — with zero HTML changes.
  • Store the user’s choice in localStorage and apply it in a synchronous inline script inside <head> to eliminate FOUC. The same technique used by every dark mode implementation.
  • Keep the Stimulus controller on <body> so theme buttons can live anywhere in the app.
  • Theme picker swatches should use hard-coded inline styles, not Tailwind classes, so they always display the target theme’s colors regardless of the currently active theme.
  • Compose with dark mode by targeting .dark [data-app-theme="..."] for intersection overrides.
  • Add new themes with a single CSS block. Zero changes elsewhere.