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:
|
|
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
|
|
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
|
|
Add to render_nav in AppLayout:
|
|
How it works
- On page load,
connect()readslocalStorage— if a preference was saved, applies it; otherwise does nothing and lets@mediadecide - On click,
toggle()detects the current effective theme (checking both the class and system preference), flips it, saves tolocalStorage, and applies - The icon updates to reflect the current state — ☀️ when dark, 🌙 when light
- All components repaint because they reference tokens, not palette values