Skip to content

Lesson 4 — Theme switching with Stimulus

What we’re adding

Lesson 3 gave us automatic dark mode via @media (prefers-color-scheme: dark). That works without any JavaScript — but the user has no way to override it from within the app. This lesson adds a manual toggle that:

  • Overrides the system preference when the user explicitly chooses
  • Persists the choice to localStorage
  • Falls back to system preference when no choice has been made

Updating application.css

Now that we have a manual toggle, we need to extend the CSS to support it. Add @custom-variant and the .dark and .light override blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

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

/* Automatic dark mode — system preference, no manual override */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-primary:        #3b82f6;
    --color-surface:        #1f2937;
    /* ... full dark token set ... */
  }
}

/* Manual dark — user explicitly chose dark */
.dark {
  --color-primary:        #3b82f6;
  --color-surface:        #1f2937;
  /* ... same full dark token set ... */
}

The :root:not(.light) selector is the key detail — the @media block only applies when the user hasn’t explicitly chosen light mode. The three states are:

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

The ThemeToggle Stimulus controller

 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
// app/javascript/controllers/theme_toggle_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["icon"]

  connect() {
    this.applyTheme(this.savedTheme)
  }

  toggle() {
    const isDark = document.documentElement.classList.contains("dark") ||
      (!document.documentElement.classList.contains("light") &&
       window.matchMedia("(prefers-color-scheme: dark)").matches)

    const next = isDark ? "light" : "dark"
    localStorage.setItem("theme", next)
    this.applyTheme(next)
  }

  applyTheme(theme) {
    const html = document.documentElement
    if (theme === "dark") {
      html.classList.add("dark")
      html.classList.remove("light")
    } else if (theme === "light") {
      html.classList.add("light")
      html.classList.remove("dark")
    } else {
      // No saved preference — remove both and let @media decide
      html.classList.remove("dark", "light")
    }
    this.updateIcon()
  }

  updateIcon() {
    if (!this.hasIconTarget) return
    const isDark = document.documentElement.classList.contains("dark") ||
      (!document.documentElement.classList.contains("light") &&
       window.matchMedia("(prefers-color-scheme: dark)").matches)
    this.iconTarget.textContent = isDark ? "☀️" : "🌙"
  }

  get savedTheme() {
    return localStorage.getItem("theme")
  }
}

Note that savedTheme now returns null when nothing is saved — meaning “respect the system preference”. The applyTheme method handles all three cases: explicit dark, explicit light, and deferred to system.

No manual registration needed. Rails 8 uses eagerLoadControllersFrom which automatically discovers and registers any file matching *_controller.js in the controllers directory. Create the file and it’s available immediately — no import or application.register call required.

Components::ThemeToggle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/components/theme_toggle.rb
class Components::ThemeToggle < Components::Base
  def view_template
    button(
      type:  "button",
      class: "p-2 rounded-md text-text-muted hover:text-text " \
             "hover:bg-surface-alt transition-colors",
      data:  {
        controller: "theme-toggle",
        action:     "click->theme-toggle#toggle"
      }
    ) do
      span(data: { theme_toggle_target: "icon" }) { "🌙" }
    end
  end
end

Add to render_nav in AppLayout:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def render_nav
  nav(class: "bg-surface border-b border-border px-4 py-3") do
    div(class: "mx-auto max-w-7xl 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") do
        ThemeToggle()
      end
    end
  end
end

How it works

  1. On page load, connect() reads localStorage — if a preference was saved, applies it; otherwise does nothing and lets @media decide
  2. On click, toggle() detects the current effective theme (checking both the class and system preference), flips it, saves to localStorage, and applies
  3. The icon updates to reflect the current state — ☀️ when dark, 🌙 when light
  4. All components repaint because they reference tokens, not palette values