A light/dark mode toggle (pp_theme_toggle/1) with a sun/moon icon in
its thumb, wired to flip data-theme="dark" on <html> (the same
attribute daisyUI and Phoenix 1.8's generated app.css already use —
see AGENTS.md, "Theming"), so it plugs straight into a dark-mode toggle
that may already exist elsewhere on the page.
<.pp_theme_toggle />
<.pp_theme_toggle label="Dark mode" target="#preview" />A vanilla onclick computes the current effective theme itself
(data-theme="dark", or — if data-theme is unset — whatever
matchMedia('(prefers-color-scheme: dark)') currently says) and sets
data-theme to the opposite, as a literal "dark"/"light" string —
it deliberately does not trust the checkbox's own checked property to
know which way to go. Two real bugs, both found from an actual dark-OS
system reproducing them, led here:
- An earlier version used
Phoenix.LiveView.JS.toggle_attribute/2's 2-argument "set-or-remove" form. That looked equivalent (and passed every existing test, since none of them exercised a system-dark OS preference) but broke oncepriv/static/phoenix_paper.cssgained its "system"prefers-color-schemefallback: removingdata-themedoesn't mean "light" anymore, it means "system" — for a user whose OS is already dark, unchecking the switch would removedata-theme="dark", and the page would immediately fall right back to dark via the system media query, making the toggle look stuck. - Fixing that by reading
this.checkedto decide"dark"/"light"still wasn't enough: a stateless function component can't know the client's OS preference at server render time, so the checkbox'scheckedattribute always starts fromdefault_checked(false). An inline<script>that set thecheckedproperty to matchmatchMediaon mount (matching the "small vanilla snippet" approachPhoenixPaper.Ripple/NumberFielduse) looked like the fix — but Phoenix LiveView's own connected-mount hydration re-renders and morphdom-patches the page shortly after the dead-rendered first paint, and that patch can replace the checkbox element with a fresh one built from the server's render (which still hasdefault_checked), silently undoing the script's mutation. The visible symptom: a dark-OS user sees a correctly-dark page next to a toggle that looks off/light — and the first click, since it read the (wrongly-reset)checkedproperty, just reasserted "dark" (a no-op the user couldn't see), so it took two clicks to actually reach light. Computing the effective theme directly in the click handler sidesteps the whole race: it never matters whether the checkbox's own property is stale, because the decision is never based on it.
Defaults to "system"
Before the first click, this doesn't force data-theme to anything —
priv/static/phoenix_paper.css's theme CSS already falls back to the
OS/browser's own prefers-color-scheme when data-theme is unset, so
the page itself already renders correctly with zero clicks, and (per the
bug above) reliably syncing the toggle's own first-paint appearance to
that same preference needs to happen in CSS too, immune to the same
hydration race — see priv/static/phoenix_paper.css's
[data-pp-component="theme-toggle"] system-preference block. Once
clicked, "system" is gone for that session (there's no way back to it
without reloading with data-theme cleared some other way — the same
one-way-door trade-off Accordion's exclusive-radio-group mode and
Breadcrumbs's expand-once ellipsis already accept for a pure-CSS/
vanilla-JS toggle with no server state).
target (default "html") is a plain CSS selector, so a toggle that
should only affect a scoped preview area instead of the whole page works
too: target="#preview".
Multiple instances stay in sync — scoped by target
A page can have more than one pp_theme_toggle (e.g. one in an AppBar
and another in a dedicated settings section) and clicking either one keeps
them all visually in sync: alongside setting data-theme, the onclick
also runs document.querySelectorAll for every
[data-pp-component="theme-toggle"][data-pp-target="..."] checkbox and
sets its checked property to match. This is plain DOM querying done at
click time — no JS hooks, no PubSub, no LiveView involved — so it works
across LiveViews on the same page just as well as within one.
The sync is scoped to toggles sharing the same target, not every
toggle on the page unconditionally: a toggle scoped to target="#preview"
and one bound to target="html" represent two independent pieces of
state (a live preview area's theme vs. the whole page's), so syncing them
together would be wrong even though both are pp_theme_toggles. Two
toggles that both default to target="html" (the common case) sync with
each other automatically, with no extra configuration needed.
on_toggle (default %JS{}) is wired as a plain phx-click, running
independently alongside the onclick above — for a caller that also
wants to persist the choice server-side, e.g.
on_toggle={JS.push("save_theme_preference")}.
Built with its own markup rather than composing PhoenixPaper.Switch (an
earlier version did) — the sun/moon icons live inside the sliding
thumb, swapped via a peer-checked: compound selector reaching into the
thumb's own children (Switch has no attr for that, and doesn't need
one for its own use cases).
Deliberately not colored with any pp-* brand token (Switch's own
thumb/track go pp-primary when checked) — a theme toggle's single most
common home is an AppBar/header, which is itself very often colored
pp-primary by default. A bg-pp-primary thumb sitting on a
bg-pp-primary app bar is the exact "same color layered on itself"
invisibility bug already documented for Drawer's colored variants and
for buttons dropped into a colored AppBar (see AGENTS.md) — rather than
fix that per-placement with a class override (Switch's architecture
doesn't expose its internal track/thumb for one anyway), this component
just never uses a background color that could plausibly match its own
container: the thumb is fixed white, the track a neutral translucent
gray, and the sun/moon icon color is what actually carries the on/off
state — all three read clearly against light backgrounds, dark
backgrounds, and colored chrome alike.
Summary
Functions
Renders a light/dark mode toggle. See the module doc.
Functions
Renders a light/dark mode toggle. See the module doc.
Attributes
id(:any) - Defaults tonil.label(:any) - text next to the switch — pass nil for icon-only. Defaults to"Dark mode".default_checked(:boolean) - initial checkbox attribute — mostly cosmetic with JS enabled (CSS handles the real first-paint sync), matters if JS is disabled. Defaults tofalse.target(:string) - CSS selector for the element to toggle data-theme on. Defaults to"html".on_toggle(Phoenix.LiveView.JS) - extra JS commands run before the built-in data-theme flip. Defaults to%Phoenix.LiveView.JS{ops: []}.ripple(:boolean) - the Material ripple effect on click/tap — off whenever paperize is false, see PhoenixPaper.Ripple. Defaults totrue.paperize(:boolean) - Defaults totrue.class(:any) - Defaults tonil.