Skip to content

Lesson 5 — Theme switching and multi-theme support

What we’re building

By the end of this lesson the nav has two controls:

  • A theme selector dropdown — Default, Forest, Ember — showing the current theme with an icon
  • A dark/light toggle — switches between dark and light within whichever theme is active

Both controls share a single Stimulus controller scoped to their parent wrapper in the nav. The components provide targets and actions; the parent provides the controller scope.

Updating application.css

First extend the CSS to support manual theme switching. Add @custom-variant and the .dark override block alongside the existing @media block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme {
  /* ... all tokens unchanged ... */
}

/* Automatic dark — OS preference, only when user hasn't chosen light */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-primary:        #3b82f6;
    --color-primary-hover:  #2563eb;
    --color-surface:        #1f2937;
    --color-surface-alt:    #111827;
    --color-surface-raised: #374151;
    --color-border:         #374151;
    --color-border-strong:  #4b5563;
    --color-text:           #f9fafb;
    --color-text-muted:     #9ca3af;
    --color-text-subtle:    #6b7280;
    --color-success-bg:     #052e16;
    --color-warning-bg:     #1c1400;
    --color-info-bg:        #0d1f3c;
  }
}

/* Manual dark — user explicitly chose dark */
.dark {
  --color-primary:        #3b82f6;
  --color-primary-hover:  #2563eb;
  --color-surface:        #1f2937;
  --color-surface-alt:    #111827;
  --color-surface-raised: #374151;
  --color-border:         #374151;
  --color-border-strong:  #4b5563;
  --color-text:           #f9fafb;
  --color-text-muted:     #9ca3af;
  --color-text-subtle:    #6b7280;
  --color-success-bg:     #052e16;
  --color-warning-bg:     #1c1400;
  --color-info-bg:        #0d1f3c;
}

The .light class needs no CSS — @theme values are already the light defaults. The .light class exists purely so :root:not(.light) evaluates to false and the @media block stands down when the user has explicitly chosen light mode.

The four states:

OS preference Manual choice Result
Light None Light (@theme defaults)
Dark None Dark (via @media)
Either Chose dark Dark (via .dark)
Either Chose light Light (.light blocks @media)

On duplication: The dark token values appear in both @media and .dark. This is a known limitation of the CSS approach — there is no clean way to define them once and reference them from both selectors. In practice dark palettes are stable once established, so this is less painful than it sounds. Change a value and update both blocks.

The named themes

Add Forest and Ember after the dark mode blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/* Forest theme — greens, calm, natural */
[data-theme="forest"] {
  --color-primary:         #16a34a;
  --color-primary-hover:   #15803d;
  --color-primary-ring:    #22c55e;
  --color-secondary:       #0891b2;
  --color-secondary-hover: #0e7490;
  --color-secondary-ring:  #22d3ee;
  --color-danger:          #dc2626;
  --color-danger-hover:    #b91c1c;
  --color-danger-ring:     #f87171;
  --color-outline-text:    #166534;
  --color-outline-border:  #16a34a;
  --color-outline-hover:   #dcfce7;
  --color-surface:         #ffffff;
  --color-surface-alt:     #f0fdf4;
  --color-surface-raised:  #ffffff;
  --color-border:          #bbf7d0;
  --color-border-strong:   #86efac;
  --color-text:            #14532d;
  --color-text-muted:      #166534;
  --color-text-subtle:     #4ade80;
}

[data-theme="forest"].dark {
  --color-primary:         #22c55e;
  --color-primary-hover:   #16a34a;
  --color-surface:         #052e16;
  --color-surface-alt:     #14532d;
  --color-surface-raised:  #166534;
  --color-border:          #166534;
  --color-border-strong:   #15803d;
  --color-text:            #f0fdf4;
  --color-text-muted:      #bbf7d0;
  --color-text-subtle:     #4ade80;
  --color-success-bg:      #052e16;
  --color-warning-bg:      #1c1400;
  --color-info-bg:         #0d1f3c;
}

/* Ember theme — reds and ambers, warm, energetic */
[data-theme="ember"] {
  --color-primary:         #dc2626;
  --color-primary-hover:   #b91c1c;
  --color-primary-ring:    #f87171;
  --color-secondary:       #d97706;
  --color-secondary-hover: #b45309;
  --color-secondary-ring:  #fbbf24;
  --color-danger:          #9f1239;
  --color-danger-hover:    #881337;
  --color-danger-ring:     #fb7185;
  --color-outline-text:    #7f1d1d;
  --color-outline-border:  #dc2626;
  --color-outline-hover:   #fef2f2;
  --color-surface:         #ffffff;
  --color-surface-alt:     #fff7f7;
  --color-surface-raised:  #ffffff;
  --color-border:          #fecaca;
  --color-border-strong:   #fca5a5;
  --color-text:            #1c0a0a;
  --color-text-muted:      #7f1d1d;
  --color-text-subtle:     #b91c1c;
  --color-success:         #16a34a;
  --color-success-bg:      #f0fdf4;
  --color-warning:         #d97706;
  --color-warning-bg:      #fffbeb;
  --color-info:            #2563eb;
  --color-info-bg:         #eff6ff;
}

[data-theme="ember"].dark {
  --color-primary:         #f87171;
  --color-primary-hover:   #ef4444;
  --color-primary-ring:    #fca5a5;
  --color-secondary:       #fbbf24;
  --color-secondary-hover: #f59e0b;
  --color-secondary-ring:  #fde68a;
  --color-outline-text:    #fca5a5;
  --color-outline-border:  #7f1d1d;
  --color-outline-hover:   #450a0a;
  --color-surface:         #1c0a0a;
  --color-surface-alt:     #270c0c;
  --color-surface-raised:  #3f1010;
  --color-border:          #7f1d1d;
  --color-border-strong:   #991b1b;
  --color-text:            #fef2f2;
  --color-text-muted:      #fca5a5;
  --color-text-subtle:     #f87171;
  --color-success-bg:      #052e16;
  --color-warning-bg:      #1c1400;
  --color-info-bg:         #0d1f3c;
}

Note that danger in Ember shifts to rose/pink (#9f1239) in light mode — without this, primary and danger buttons look identical, which is a serious UX problem.

The Stimulus controller

One controller manages both dark/light toggling and theme selection. It’s registered automatically by eagerLoadControllersFrom — no manual registration needed.

There’s quite a lot of code in this Stimulus controller. We won’t go into it here, we’ll be looking a bit deeper into Stimulus in the next module (Module 8), and if that’s not enough, you can check out the Stimulus Tutorial(under development).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
// app/javascript/controllers/theme_toggle_controller.js
import { Controller } from "@hotwired/stimulus"

const THEMES = {
  default: { icon: "💧", label: "Default" },
  forest:  { icon: "🌲", label: "Forest"  },
  ember:   { icon: "🔥", label: "Ember"   },
}

export default class extends Controller {
  static targets = [
    "icon",           // dark/light toggle icon
    "currentIcon",    // theme selector — current theme icon
    "currentLabel",   // theme selector — current theme label
    "dropdown",       // theme selector — dropdown menu
    "themeOption",    // theme selector — each option button
  ]

  connect() {
    this.applyTheme(this.savedTheme)
    this.applyMode(this.savedMode)
    this._closeOnOutside = this.closeOnOutsideClick.bind(this)
    document.addEventListener("click", this._closeOnOutside)
  }

  disconnect() {
    document.removeEventListener("click", this._closeOnOutside)
  }

  // Dark / light toggle
  toggle() {
    const next = this.currentlyDark ? "light" : "dark"
    localStorage.setItem("mode", next)
    this.applyMode(next)
  }

  // Theme dropdown
  toggleDropdown() {
    this.dropdownTarget.hidden = !this.dropdownTarget.hidden
  }

  selectTheme(event) {
    const themeId = event.currentTarget.dataset.themeId
    localStorage.setItem("theme", themeId)
    this.applyTheme(themeId)
    this.dropdownTarget.hidden = true
  }

  closeOnOutsideClick(event) {
    if (this.hasDropdownTarget && !this.element.contains(event.target)) {
      this.dropdownTarget.hidden = true
    }
  }

  // Internal

  applyTheme(themeId) {
    document.documentElement.dataset.theme = themeId === "default" ? "" : themeId
    this.updateThemeIndicator(themeId)
  }

  applyMode(mode) {
    const html = document.documentElement
    if (mode === "dark") {
      html.classList.add("dark")
      html.classList.remove("light")
    } else if (mode === "light") {
      html.classList.add("light")
      html.classList.remove("dark")
    } else {
      html.classList.remove("dark", "light")
    }
    this.updateModeIcon()
  }

  updateModeIcon() {
    if (this.hasIconTarget) {
      this.iconTarget.textContent = this.currentlyDark ? "☀️" : "🌙"
    }
  }

  updateThemeIndicator(themeId) {
    const theme = THEMES[themeId] || THEMES.default
    if (this.hasCurrentIconTarget)  this.currentIconTarget.textContent  = theme.icon
    if (this.hasCurrentLabelTarget) this.currentLabelTarget.textContent = theme.label

    if (this.hasThemeOptionTarget) {
      this.themeOptionTargets.forEach(option => {
        const active = option.dataset.themeId === themeId
        option.classList.toggle("font-semibold", active)
        option.classList.toggle("text-primary",   active)
      })
    }
  }

  get currentlyDark() {
    return document.documentElement.classList.contains("dark") ||
      (!document.documentElement.classList.contains("light") &&
       window.matchMedia("(prefers-color-scheme: dark)").matches)
  }

  get savedTheme() { return localStorage.getItem("theme") || "default" }
  get savedMode()  { return localStorage.getItem("mode") }
}

The components

Two small components — one for the dark/light toggle, one for the theme dropdown. Neither declares data-controller — the controller scope comes from their parent wrapper in the nav.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app/components/theme_toggle.rb
class Components::ThemeToggle < Components::Base
  def view_template
    button(
      type:  "button",
      title: "Toggle dark mode",
      class: "p-2 rounded-md text-text-muted hover:text-text " \
             "hover:bg-surface-alt transition-colors",
      data:  { action: "click->theme-toggle#toggle" }
    ) do
      span(data: { theme_toggle_target: "icon" }) { "🌙" }
    end
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# app/components/theme_selector.rb
class Components::ThemeSelector < Components::Base
  THEMES = [
    { id: "default", label: "Default", icon: "💧" },
    { id: "forest",  label: "Forest",  icon: "🌲" },
    { id: "ember",   label: "Ember",   icon: "🔥" },
  ].freeze

  def view_template
    div(class: "relative") do
      button(
        type:  "button",
        title: "Change theme",
        class: "flex items-center gap-2 px-3 py-2 text-sm rounded-md " \
               "text-text-muted hover:text-text hover:bg-surface-alt " \
               "transition-colors",
        data:  { action: "click->theme-toggle#toggleDropdown" }
      ) do
        span(data: { theme_toggle_target: "currentIcon" }) { "💧" }
        span(data:  { theme_toggle_target: "currentLabel" },
             class: "hidden sm:inline text-sm") { "Default" }
        span(class: "text-xs opacity-60") { "▾" }
      end

      div(
        class:  "absolute right-0 mt-1 w-44 bg-surface border border-border " \
                "rounded-md shadow-lg py-1 z-50",
        hidden: true,
        data:   { theme_toggle_target: "dropdown" }
      ) do
        THEMES.each do |theme|
          button(
            type:  "button",
            class: "flex items-center gap-3 w-full px-4 py-2 text-sm " \
                   "text-text hover:bg-surface-alt transition-colors",
            data:  {
              action:              "click->theme-toggle#selectTheme",
              theme_toggle_target: "themeOption",
              theme_id:            theme[:id]
            }
          ) do
            span { theme[:icon] }
            plain theme[:label]
          end
        end
      end
    end
  end
end

Wiring it up in AppLayout

Both components sit inside a single data-controller wrapper in render_nav:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def render_nav
  nav(class: "bg-surface border-b border-border px-4 py-3") do
    div(class: "flex items-center justify-between") do
      a(href: root_path, class: "font-bold text-lg text-text") { "KanbanFlow" }

      div(
        class: "flex items-center gap-2",
        data:  { controller: "theme-toggle" }
      ) do
        ThemeSelector()
        ThemeToggle()
      end
    end
  end
end

The data-controller="theme-toggle" wrapper on the flex div scopes the controller to both components. ThemeSelector handles theme selection; ThemeToggle handles dark/light switching. Both fire actions on the same controller instance because they share the same scope.

How it all works

On page loadconnect() reads localStorage for both the saved theme and mode, applies both, and updates the UI indicators.

Theme selection — clicking a theme option calls selectTheme(), which sets data-theme on <html>, updates the dropdown indicator, and saves to localStorage. The CSS [data-theme="forest"] selector immediately overrides the default tokens.

Dark/light toggle — clicking the moon/sun button calls toggle(), which adds .dark or .light to <html> and saves to localStorage. The existing dark tokens take effect immediately.

On reloadconnect() restores both preferences. If no mode is saved, the OS preference is respected via @media. If no theme is saved, the default tokens apply.

The key lesson

Open any component file — Button, Card, Alert. Search for “forest” or “ember”. Neither word appears. Components reference bg-primary and text-text. The theme provides what those mean. The entire visual identity of the app changes without touching a single component.

This is the payoff of the token system introduced in Lesson 1.