Dark mode

View Source

Package: 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:

Already wired?

PieceExpect
PlugMyAppWeb.Plugs.Mode in the browser pipeline; assigns :mode
BridgeInline <script> in <head> listens for phx:set-mode and writes localStorage / cookie / data-mode
HookToggle 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>
  """
end

on_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

SymptomCheck
Toggle does nothingBridge listens for phx:set-mode; Toggle hook is registered
Wrong pressed statepressed={@mode == "dark"} and the layout receives :mode
Flash of wrong modeBridge <script> is in <head>; root data-mode matches the plug assign
Tabs driftBridge storage listener is present (install wiring)