Theming
View SourceIntroduction
You wire a theme picker (neo, uno, duo, leo) into a Phoenix + Corex app. The result is data-theme on <html> that persists across reloads. Theme is independent from light/dark mode; Corex Design combines them as [data-theme="neo"][data-mode="dark"].
Static Tableau sites use the same data-theme pattern without plugs—see Tableau Theming. Mode is covered in Dark mode.
Before you start
| Requirement | Notes |
|---|---|
| Corex installed | Manual installation or mix corex.new --theme |
| Theme CSS imported | One @import per theme you expose in the picker |
select hook | Registered in assets/js/app.js |
How it works
config :my_app, :themesis the single source of truth; the first entry is the default.Plugs.Themereadsphx_theme, validates against config, assigns:theme.- Bridge script reconciles
localStorage,data-theme, and the default. <.select on_value_change_client="phx:set-theme">updates theme without a server round-trip.
Config and plug
config/config.exs:
config :my_app, :themes, ~w(neo uno duo leo)lib/my_app_web/plugs/theme.ex:
defmodule MyAppWeb.Plugs.Theme do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
themes = Application.get_env(:my_app, :themes, ["neo"])
default_theme = List.first(themes) || "neo"
theme =
conn.cookies["phx_theme"]
|> parse_theme(themes, default_theme)
conn
|> assign(:theme, theme)
|> assign(:themes, themes)
|> put_session(:theme, theme)
end
defp parse_theme(nil, _themes, default), do: default
defp parse_theme(theme, themes, default) do
if theme in themes, do: theme, else: default
end
endBrowser pipeline (after :fetch_live_flash; with --lang, put Mode/Theme plugs after localize plugs):
plug MyAppWeb.Plugs.Mode
plug MyAppWeb.Plugs.ThemeLayout and theme bridge
root.html.heex:
<html lang="en" data-theme={assigns[:theme] || "neo"} data-mode={assigns[:mode] || "light"}>Theme bridge in <head> (merge into the same IIFE as Dark mode when you use both):
<script>
(() => {
const validThemes = ["neo", "uno", "duo", "leo"];
const setTheme = (theme) => {
const resolved = validThemes.includes(theme) ? theme : "neo";
localStorage.setItem("phx:theme", resolved);
document.cookie = "phx_theme=" + resolved + "; path=/; max-age=31536000";
document.documentElement.setAttribute("data-theme", resolved);
};
setTheme(
localStorage.getItem("phx:theme") ||
document.documentElement.getAttribute("data-theme") ||
"neo"
);
window.addEventListener(
"storage",
(e) => e.key === "phx:theme" && e.newValue && setTheme(e.newValue)
);
window.addEventListener("phx:set-theme", (e) => {
const value = e.detail?.value;
const theme = Array.isArray(value) && value[0] ? value[0] : "neo";
setTheme(theme);
});
})();
</script>Keep validThemes and fallbacks in sync with config :my_app, :themes.
Theme picker
def theme_toggle(assigns) do
~H"""
<.select
id="theme-select"
class="select select--sm"
items={[
%{value: "neo", label: "Neo"},
%{value: "uno", label: "Uno"},
%{value: "duo", label: "Duo"},
%{value: "leo", label: "Leo"}
]}
value={[@theme]}
on_value_change_client="phx:set-theme"
>
<:label class="sr-only">Theme</:label>
<:item :let={item}>{item.label}</:item>
<:trigger>
<.heroicon name="hero-swatch" class="icon" />
</:trigger>
<:item_indicator>
<.heroicon name="hero-check" class="icon" />
</:item_indicator>
</.select>
"""
end<Layouts.app flash={@flash} theme={assigns[:theme] || "neo"} mode={assigns[:mode] || "light"}>
<h1>{gettext("Home")}</h1>
</Layouts.app>LiveView on_mount
defmodule MyAppWeb.ThemeLive do
def on_mount(:default, _params, session, socket) do
theme = session["theme"] || "neo"
{:cont, Phoenix.Component.assign(socket, :theme, theme)}
end
endCSS
@import "../corex/main.css";
@import "../corex/theme/neo.css";
@import "../corex/theme/uno.css";
@import "../corex/theme/duo.css";
@import "../corex/theme/leo.css";
@import "../corex/components/select.css";Related
- Dark mode — combine bridges in one
<script>block - Tableau Theming — static site equivalent
- Localize — session layout hook may also assign theme and mode