Dark mode
View SourcePackage: corex_design. Full guide on corex Hexdocs.
Introduction
Visitors switch light and dark appearance with a Corex <.toggle>. The choice updates data-mode on <html> without a server round-trip, drives Corex Design tokens, and matches what they chose on the last visit.
There is no OTP allowlist for mode (only light / dark). config :corex_design, default_mode: is a build-time default for generated CSS, not the runtime control. See Configuration and Design. Static Tableau sites use the same data-mode idea without plugs; see Tableau Mode.
Install first
Wire the mode plug, bridge script, and toggle hook before you drop this UI into a layout:
- New app:
mix corex.new my_app --mode - Existing app: Manual installation: optional mode wiring
Already wired?
| Piece | Expect |
|---|---|
| Plug | MyAppWeb.Plugs.Mode in the browser pipeline; assigns :mode |
| Bridge | Inline <script> in <head> listens for phx:set-mode and writes localStorage / cookie / data-mode |
| Hook | Toggle registered in assets/js/app.js |
| CSS | @import "../corex/corex.css" and toggle in components: when you use Design |
Mode toggle
In layouts.ex (or a dedicated component module):
attr :flash, :map, required: true
attr :mode, :string, default: "light"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="layout__header">
<.mode_toggle mode={@mode} />
</header>
<main class="layout__main">
<div class="layout__content">
{render_slot(@inner_block)}
</div>
</main>
"""
end
attr :mode, :string, default: "light", values: ["light", "dark"]
def mode_toggle(assigns) do
~H"""
<.toggle
id="mode-switcher"
class="toggle ui-size-sm"
data-toggle-dual-label
pressed={@mode == "dark"}
on_pressed_change_client="phx:set-mode"
>
<span>
<.heroicon name="hero-moon" />
<span class="sr-only">Dark mode</span>
</span>
<span data-pressed>
<.heroicon name="hero-sun" />
<span class="sr-only">Light mode</span>
</span>
</.toggle>
"""
endon_pressed_change_client="phx:set-mode" fires a browser event the mode bridge handles (pressed: true → dark, false → light).
Layout placement
Pass mode into the layout from every LiveView and controller template:
<Layouts.app flash={@flash} mode={assigns[:mode] || "light"}>
<h1>{gettext("Home")}</h1>
</Layouts.app>With LiveViews, ensure :mode is on the socket (session via Plugs.Mode, or on_mount / Hooks.Layout when you also use --lang). Root <html> should carry data-mode={assigns[:mode] || "light"} so the first paint matches the plug.
CSS
@import "../corex/corex.css";corex.css loads utilities, themes, and components. Include toggle in components: when you use a mode switcher. For layered imports, see Design.
Corex Design themes define [data-mode=dark] overrides. Custom CSS can target [data-mode="dark"] the same way.
Bridge
Before-paint script in <head> (merge into the same IIFE as Theming when you use both):
<script>
(() => {
const getSystemMode = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const setMode = (mode) => {
const resolved = mode === "dark" || mode === "light" ? mode : getSystemMode();
localStorage.setItem("phx:mode", resolved);
document.cookie = "phx_mode=" + resolved + "; path=/; max-age=31536000";
document.documentElement.setAttribute("data-mode", resolved);
};
setMode(
localStorage.getItem("phx:mode") ||
document.documentElement.getAttribute("data-mode") ||
getSystemMode()
);
window.addEventListener(
"storage",
(e) => e.key === "phx:mode" && e.newValue && setMode(e.newValue)
);
window.addEventListener("phx:set-mode", (e) => {
const detail = e.detail;
if (typeof detail?.pressed === "boolean") {
setMode(detail.pressed ? "dark" : "light");
return;
}
const value = detail?.value;
const mode = Array.isArray(value) && value[0] ? value[0] : "light";
setMode(mode);
});
})();
</script>Resolution order: localStorage["phx:mode"], then data-mode from the server, then prefers-color-scheme.
Troubleshooting
| Symptom | Check |
|---|---|
| Toggle does nothing | Bridge listens for phx:set-mode; Toggle hook is registered |
| Wrong pressed state | pressed={@mode == "dark"} and the layout receives :mode |
| Flash of wrong mode | Bridge <script> is in <head>; root data-mode matches the plug assign |
| Tabs drift | Bridge storage listener is present (install wiring) |
Related
- Manual installation: plug, bridge, and pipeline
- Configuration: build vs picker vs generators
- Theming:
data-theme; combine both bridges in one<script>IIFE - Accessibility: optional preference axes; same bridge pattern
- Tableau Mode: static site equivalent
- Installation:
mix corex.new --mode