Theming
View SourceIntroduction
This guide walks through wiring a theme picker (skins like neo, uno, duo, leo) into a Phoenix + Corex app. The result is a data-theme="neo" attribute on <html> driven by the user's selection, persisted across reloads.
Theme is independent from light/dark mode. The Corex Design tokens combine the two: a CSS rule like [data-theme="neo"][data-mode="dark"] is what you'd target if you wanted Neo's dark variant. Mode is covered in Dark mode; this guide is theme-only, but they share the same bridge script and slot together cleanly.
If you ran mix corex.new my_app --theme, the installer wrote everything below for you. Use this guide to understand what that produced, or to wire it by hand in an existing app.
See Installation for Corex-only flags (including --mcp). For the underlying Corex install, see Manual installation.
The problem
Like dark mode, theme has to be known at render time. The server has to set the right data-theme on <html> so the first paint already uses the correct CSS tokens — switching client-side after paint causes a visible flash.
The solution
The same three-layer pattern as dark mode:
- Cookie + Plug —
Plugs.Themereadsphx_themeand assigns:theme. - Inline
<script>in<head>— reconcileslocalStorage["phx:theme"],data-theme, and the configured default; persists the cookie back. phx:set-themewindow event — the Corex select dispatches it on change.
1. Configure the theme list
The Corex installer writes the available themes into your application config so the plug, the bridge script, and any UI can read the same list. In config/config.exs:
config :my_app, :themes, ~w(neo uno duo leo)The first entry is the default theme used when no cookie is set. Use the subset that matches the Corex Design themes you import in app.css — there is no point exposing leo in the picker if you never @import "../corex/theme/leo.css".
2. Create the Theme plug
Create lib/my_app_web/plugs/theme.ex. It reads phx_theme from the cookies, validates it against your configured :themes list, and falls back to the first one when the cookie is missing or invalid. It also exposes the full list as :themes so the picker can render it without re-reading config. Use your real otp_app atom in Application.get_env/3 (shown as :my_app below); mix corex.new emits the correct name automatically.
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
end3. Add the plug to the browser pipeline
Mount it in lib/my_app_web/router.ex after :fetch_live_flash. With mix corex.new --lang, Localize.Plug.PutLocale / PutSession run first; Mode and Theme plugs are inserted after those. If you only use mode/theme, the two plugs sit side by side (either order is fine).
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug MyAppWeb.Plugs.Mode
plug MyAppWeb.Plugs.Theme
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end4. Update the root layout
In lib/my_app_web/components/layouts/root.html.heex, expose data-theme on <html> from the assign, defaulting to "neo" (or whichever theme you put first in :themes):
<!DOCTYPE html>
<html lang="en" data-theme={assigns[:theme] || "neo"} data-mode={assigns[:mode] || "light"}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="MyApp" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="module" src={~p"/assets/js/app.js"}></script>
</head>
<body class="typo layout">
{@inner_content}
</body>
</html>type="module" on the <script> tag is required by the Corex JS bundle — see Manual installation if you have not set it yet.
5. Add the bridge script
Inside <head>, before the closing </head>, add the bridge script. It runs synchronously on first paint, reconciles localStorage ↔ data-theme ↔ default, persists the cookie back, and listens for phx:set-theme from the picker.
If you already have the dark-mode bridge from Dark mode, add this block right after it inside the same <script> IIFE — they share the same lifecycle.
<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 the fallback string in sync with your config :my_app, :themes list. If you only ship neo and uno, set validThemes = ["neo", "uno"].
6. Add a theme picker to the app layout
Use Corex.Select. The on_value_change_client="phx:set-theme" attribute makes it dispatch the same window event the bridge script listens for — no handle_event/3 needed.
In lib/my_app_web/components/layouts.ex, add a :theme attr to your app/1 and render the picker in the header:
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :mode, :string, default: "light", doc: "current mode (light or dark)"
attr :theme, :string, default: "neo", doc: "current theme"
attr :current_scope, :map, default: nil
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="layout__header">
<div class="layout__header__content">
<div class="layout__row">
<.theme_toggle theme={@theme} />
<.mode_toggle mode={@mode} />
</div>
</div>
</header>
<main class="layout__main">
<div class="layout__content">
{render_slot(@inner_block)}
</div>
</main>
"""
end
attr :theme, :string,
default: "neo",
values: ["neo", "uno", "duo", "leo"],
doc: "current theme"
def theme_toggle(assigns) do
~H"""
<.select
id="theme-select"
class="select select--sm"
items={[
%{id: "neo", label: "Neo"},
%{id: "uno", label: "Uno"},
%{id: "duo", label: "Duo"},
%{id: "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>
"""
endThen make sure every page passes theme={@theme} (or theme={assigns[:theme] || "neo"}) into the layout:
<Layouts.app flash={@flash} theme={assigns[:theme] || "neo"} mode={assigns[:mode] || "light"}>
<h1>{gettext("Home")}</h1>
</Layouts.app>For LiveViews, attach a small on_mount hook that pulls :theme from the session into the socket. If you used mix corex.new … --lang, the installer adds on_mount MyAppWeb.Hooks.Layout after use Phoenix.LiveView, which assigns theme (and mode, current_path) from the session — you only need a dedicated ThemeLive below if you do not use that hook.
defmodule MyAppWeb.ThemeLive do
def on_mount(:default, _params, session, socket) do
theme = session["theme"] || "neo"
{:cont, Phoenix.Component.assign(socket, :theme, theme)}
end
enddef live_view do
quote do
use Phoenix.LiveView
on_mount MyAppWeb.ModeLive
on_mount MyAppWeb.ThemeLive
unquote(html_helpers())
end
end7. Styling
Import each theme you want available, plus the select component CSS that styles the picker. In assets/css/app.css:
@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/typo.css";
@import "../corex/components/layout.css";
@import "../corex/components/select.css";Each theme/*.css file scopes its tokens under [data-theme="<name>"], so all four can coexist in the same bundle — the active one is whichever the <html> attribute names.
If you also use Corex Design with Dark mode, each theme file already defines a [data-theme="<name>"][data-mode="dark"] variant. Theme and mode compose without extra setup.
8. Changing the default theme
The default is the first entry of config :my_app, :themes. Reorder it to change the fallback for new visitors (and anyone who hasn't picked a theme yet):
config :my_app, :themes, ~w(uno neo duo leo)If you change the default, also update:
- The fallback in the bridge script (
"neo"→ your new default in two places). - The
:themedefault inLayouts.app/1and anytheme_toggle/1attr :theme, default: .... - The fallback in your
<html data-theme={... || "neo"}>attribute.
Plugs.Theme already picks up List.first(themes) automatically.
Summary
- Config —
config :my_app, :themes, ~w(neo uno duo leo)is the single source of truth; first entry is the default. - Cookie —
Plugs.Themereadsphx_theme, validates against the config, assigns:themeand:themes. - Server-rendered
data-theme—<html data-theme={assigns[:theme] || "neo"}>carries the value into the first paint. - Inline
<script>in<head>— reconcileslocalStorage↔data-theme↔ default, persists the cookie, and listens forphx:set-theme. Corex.Select—on_value_change_client="phx:set-theme"dispatches the event the bridge listens for; no server round-trip.- CSS — every theme you list also has to be
@imported;select.cssstyles the picker.
Related
- Dark mode — same pattern for
data-mode; combine the two bridges in one<script>block. - Localize —
Hooks.Layoutcarries session theme/mode when--langis enabled. - Installation — the
--themeflag wires the installer output; see also--mcp/--no-mcpthere.