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:
|
|
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
@mediaand.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:
|
|
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).
|
|
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.
|
|
|
|
Wiring it up in AppLayout
Both components sit inside a single data-controller wrapper in
render_nav:
|
|
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 load — connect() 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 reload — connect() 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.