Dark Mode
View SourceIntroduction
This guide will walk you through setting up a dark mode toggle using the Corex.ToggleGroup component in Phoenix LiveView.
This approach uses Plugs to load the correct mode on initial render, affecting both styling and component initialization.
The Problem
The classic approach using only local storage works well for CSS-only components, as data attributes are available as soon as the page starts to load (via the initial script in root.html.heex).
However, Corex components need to know their initial props on render to:
- Avoid FOUC (Flash of Unstyled Content)
- Initialize component accessibility correctly on page render
The Solution
Use both local storage and cookies in conjunction with a Plug to get the current mode on render.
For LiveView, we add a Live Hook to assign the mode to the socket.
Implementation
1. Create the Mode Plug
Create a plug that reads the mode from the phx_mode cookie and puts it in assigns and session. This allows the server to render the correct mode in the initial HTML without any flash.
defmodule MyAppWeb.Plugs.Mode do
@moduledoc """
Reads the mode from the phx_mode cookie and puts it in assigns and session.
Allows the server to render the correct mode in the initial HTML (no flash).
"""
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
mode =
conn.cookies["phx_mode"]
|> parse_mode()
conn
|> assign(:mode, mode)
|> put_session(:mode, mode)
end
defp parse_mode("dark"), do: "dark"
defp parse_mode(_), do: "light"
end2. Add the Plug to Your Router
Add the MyAppWeb.Plugs.Mode plug to your browser pipeline in router.ex:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug MyAppWeb.Plugs.Mode
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end3. Create the LiveView Mode Hook
Create a LiveView hook that assigns the mode from the session to the socket:
defmodule MyAppWeb.ModeLive do
@moduledoc """
Assigns the mode from the session to the LiveView socket.
"""
def on_mount(:default, _params, session, socket) do
mode = session["mode"] || "light"
{:cont, Phoenix.Component.assign(socket, :mode, mode)}
end
end4. Configure Your Web Module
Add the hook to your live_view function in your web module (typically my_app_web.ex):
def live_view do
quote do
use Phoenix.LiveView
on_mount MyAppWeb.ModeLive
unquote(html_helpers())
end
end5. Configure Your Root Layout
Update your root.html.heex to include the data-mode attribute and the client-side script that handles mode switching:
<!DOCTYPE html>
<html lang="en" data-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="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<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);
};
// Initialize mode from localStorage, server-rendered attribute, or system preference
setMode(
localStorage.getItem("phx:mode") ||
document.documentElement.getAttribute("data-mode") ||
getSystemMode()
);
// Sync mode across tabs
window.addEventListener("storage", (e) => {
if (e.key === "phx:mode" && e.newValue) {
setMode(e.newValue);
}
});
// Handle mode toggle from LiveView
window.addEventListener("phx:set-mode", (e) => {
const value = e.detail?.value;
const mode = Array.isArray(value) && value[0] ? value[0] : "light";
setMode(mode);
});
})();
</script>
</head>
<body class="typo layout">
{@inner_content}
</body>
</html>Key points about this script:
- Runs immediately on page load (before body renders) to prevent FOUC
- Checks localStorage first for user preference
- Falls back to server-rendered
data-modeattribute from the Plug - Falls back to system preference if neither is available
- Syncs mode changes across browser tabs via
storageevent - Listens for
phx:set-modeevents from the toggle component - Updates both localStorage and cookie to keep client and server in sync
6. Update Your Layout Module
Create your layout with the mode toggle component:
We will add attr :mode, :string, default: "light" to our app and include the Corex.ToggleGroup
defmodule MyAppWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use MyAppWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash} mode={@mode}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :mode, :string, default: "light", doc: "the mode (dark or light) from cookie/session"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<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"],
doc: "the mode (dark or light) from cookie/session"
@doc """
Provides dark vs light theme toggle using toggle_group.
"""
def mode_toggle(assigns) do
~H"""
<.toggle_group
id="mode-switcher"
class="toggle-group toggle-group--sm toggle-group--circle toggle-group--inverted"
value={if @mode == "dark", do: ["dark"], else: []}
on_value_change_client="phx:set-mode"
>
<:item value="dark">
<.icon name="hero-sun" class="icon state-on" />
<.icon name="hero-moon" class="icon state-off" />
</:item>
</.toggle_group>
"""
end
end7. Styling
If you followed the initial installation guide or using Corex Design you can import the toggle-group and dark mode css
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/tokens/themes/neo/dark.css";
@import "../corex/components/toggle-group.css";Summary
This implementation ensures that:
- The mode is read from cookies on initial page load (via Plug)
- The mode is available in both assigns and session
- A client-side script in
root.html.heexinitializes the mode immediately to prevent FOUC - The script prioritizes: localStorage → server-rendered attribute → system preference
- LiveView receives the mode through the
on_mounthook - Components render with the correct mode from the start
- Mode changes sync across browser tabs via localStorage events
- Both cookie and localStorage are updated to keep client and server state in sync
The key is using a triple-layer approach:
- Cookies for server-side persistence (Plug)
- localStorage for client-side persistence across sessions
- Immediate script execution in the HTML head to prevent visual flash
This ensures seamless dark mode switching with no flicker, proper SSR support, and consistent state across tabs and page reloads.