Theming
Every color in PhoenixPaper is one CSS custom property. You set your palette by overriding those properties in your own app.css: once for light, once for dark. No build step, no JavaScript config, no forking the dependency.
Setting your colors
Add your overrides to app.css after the phoenix_paper import. Two blocks: light on :root, dark on the data-theme block the toggle switches. Every token phoenix_paper defines is listed below at its default value, so the whole set is in front of you: edit the hexes you want and delete the lines you don't (a line you drop keeps phoenix_paper's own value).
That is the entire theming API. There is no config file and no JS: the components
resolve
var(--color-pp-primary)
at paint time, so a token change takes effect on the next repaint with no rebuild.
Do not edit
deps/phoenix_paper/priv/static/phoenix_paper.css
directly: your changes belong in your app so an upgrade never clobbers them.
These two blocks cover an explicit data-theme. ThemeToggle's
default, System, has no
data-theme
at all (this site starts that way), so to follow the OS also add the
@media (prefers-color-scheme: dark)
block from Light and dark: how the switch works, below, with the same dark values.
Show code
@import "tailwindcss";
@import "../../deps/phoenix_paper/priv/static/phoenix_paper.css";
/* assets/css/app.css, after the phoenix_paper import */
/* Light (default) */
:root {
/* Primary */
--color-pp-primary: #6750a4;
--color-pp-on-primary: #ffffff;
--color-pp-primary-container: #eaddff;
--color-pp-on-primary-container: #4f378b;
/* Secondary */
--color-pp-secondary: #625b71;
--color-pp-on-secondary: #ffffff;
--color-pp-secondary-container: #e8def8;
--color-pp-on-secondary-container: #4a4458;
/* Tertiary */
--color-pp-tertiary: #7d5260;
--color-pp-on-tertiary: #ffffff;
--color-pp-tertiary-container: #ffd8e4;
--color-pp-on-tertiary-container: #633b48;
/* Error */
--color-pp-error: #b3261e;
--color-pp-on-error: #ffffff;
--color-pp-error-container: #f9dedc;
--color-pp-on-error-container: #8c1d18;
/* Surface */
--color-pp-surface: #fef7ff;
--color-pp-surface-dim: #ded8e1;
--color-pp-surface-bright: #fef7ff;
--color-pp-surface-container-lowest: #ffffff;
--color-pp-surface-container-low: #f7f2fa;
--color-pp-surface-container: #f3edf7;
--color-pp-surface-container-high: #ece6f0;
--color-pp-surface-container-highest: #e6e0e9;
--color-pp-surface-variant: #e7e0ec;
--color-pp-on-surface: #1d1b20;
--color-pp-on-surface-variant: #49454f;
/* Outline */
--color-pp-outline: #79747e;
--color-pp-outline-variant: #cac4d0;
/* Inverse */
--color-pp-inverse-surface: #322f35;
--color-pp-inverse-on-surface: #f5eff7;
--color-pp-inverse-primary: #d0bcff;
/* Fixed */
--color-pp-primary-fixed: #eaddff;
--color-pp-primary-fixed-dim: #d0bcff;
--color-pp-on-primary-fixed: #21005d;
--color-pp-on-primary-fixed-variant: #4f378b;
--color-pp-secondary-fixed: #e8def8;
--color-pp-secondary-fixed-dim: #ccc2dc;
--color-pp-on-secondary-fixed: #1d192b;
--color-pp-on-secondary-fixed-variant: #4a4458;
--color-pp-tertiary-fixed: #ffd8e4;
--color-pp-tertiary-fixed-dim: #efb8c8;
--color-pp-on-tertiary-fixed: #31111d;
--color-pp-on-tertiary-fixed-variant: #633b48;
/* Other */
--color-pp-shadow: #000000;
--color-pp-scrim: #000000;
}
/* Dark */
[data-theme="dark"] {
/* Primary */
--color-pp-primary: #d0bcff;
--color-pp-on-primary: #381e72;
--color-pp-primary-container: #4f378b;
--color-pp-on-primary-container: #eaddff;
/* Secondary */
--color-pp-secondary: #ccc2dc;
--color-pp-on-secondary: #332d41;
--color-pp-secondary-container: #4a4458;
--color-pp-on-secondary-container: #e8def8;
/* Tertiary */
--color-pp-tertiary: #efb8c8;
--color-pp-on-tertiary: #492532;
--color-pp-tertiary-container: #633b48;
--color-pp-on-tertiary-container: #ffd8e4;
/* Error */
--color-pp-error: #f2b8b5;
--color-pp-on-error: #601410;
--color-pp-error-container: #8c1d18;
--color-pp-on-error-container: #f9dedc;
/* Surface */
--color-pp-surface: #141218;
--color-pp-surface-dim: #141218;
--color-pp-surface-bright: #3b383e;
--color-pp-surface-container-lowest: #0f0d13;
--color-pp-surface-container-low: #1d1b20;
--color-pp-surface-container: #211f26;
--color-pp-surface-container-high: #2b2930;
--color-pp-surface-container-highest: #36343b;
--color-pp-surface-variant: #49454f;
--color-pp-on-surface: #e6e0e9;
--color-pp-on-surface-variant: #cac4d0;
/* Outline */
--color-pp-outline: #938f99;
--color-pp-outline-variant: #49454f;
/* Inverse */
--color-pp-inverse-surface: #e6e0e9;
--color-pp-inverse-on-surface: #322f35;
--color-pp-inverse-primary: #6750a4;
/* Fixed */
--color-pp-primary-fixed: #eaddff;
--color-pp-primary-fixed-dim: #d0bcff;
--color-pp-on-primary-fixed: #21005d;
--color-pp-on-primary-fixed-variant: #4f378b;
--color-pp-secondary-fixed: #e8def8;
--color-pp-secondary-fixed-dim: #ccc2dc;
--color-pp-on-secondary-fixed: #1d192b;
--color-pp-on-secondary-fixed-variant: #4a4458;
--color-pp-tertiary-fixed: #ffd8e4;
--color-pp-tertiary-fixed-dim: #efb8c8;
--color-pp-on-tertiary-fixed: #31111d;
--color-pp-on-tertiary-fixed-variant: #633b48;
/* Other */
--color-pp-shadow: #000000;
--color-pp-scrim: #000000;
}
Generate from a seed color
Rather than picking 46 colors, generate them: mix phoenix_paper.gen.theme takes one seed color and writes every role, light and dark, with phoenix_paper's port of Material's HCT color science, the same way Material Theme Builder does. Every role is a fixed tone of a tonal palette, so contrast holds for any hue. Pick a scheme for the palettes' character, pin core colors if your brand has them, then import the file after phoenix_paper.css.
Prefer to see it first? The Theme Creator uses the same generator: pick a seed and a scheme, fine-tune any token, and copy the CSS.
Show code
/* In a terminal: one seed, every role (light and dark).
mix phoenix_paper.gen.theme --seed "#0b57d0"
Schemes: tonal_spot (default), neutral, vibrant, expressive, fidelity, monochrome.
Pin core colors with --secondary / --tertiary / --neutral / --error:
mix phoenix_paper.gen.theme --seed "#0b57d0" --scheme vibrant --tertiary "#a4407f"
It writes assets/css/phoenix_paper_theme.css (--output to change it). */
/* assets/css/app.css */
@import "tailwindcss";
@import "../../deps/phoenix_paper/priv/static/phoenix_paper.css";
@import "./phoenix_paper_theme.css";
The token model
PhoenixPaper uses Material Design 3's color roles: each is a --color-pp-* custom property the components read, and each ships as a background/foreground pair. Override a role and every component that uses it updates. The Theme Creator edits all of them with a live preview.
These read live off the current theme: flip the mode in the next section, or pick hues in the theme picker
on-primary text
on-primary-container text
on-secondary text
on-secondary-container text
on-tertiary text
on-tertiary-container text
on-error text
on-error-container text
| Option | Description |
|---|---|
primary / on-primary
|
high-emphasis actions and active states: filled buttons, selected controls, progress |
*-container / on-*-container
|
lower-emphasis fills in that role's hue: tonal buttons, FABs (primary-container), the navigation indicator (secondary-container) |
secondary, tertiary
|
secondary is a muted companion of primary for less prominent UI; tertiary adds a contrasting accent |
error (+ container)
|
destructive actions and invalid fields |
surface, surface-container-lowest … -highest, surface-dim / -bright
|
backgrounds, layered by color rather than shadow: cards, sheets, menus, dialogs, the app bar when scrolled |
on-surface / on-surface-variant
|
text and icons; the variant for secondary text and inactive icons |
outline / outline-variant
|
borders; the variant for dividers and subtle outlines |
inverse-surface / inverse-on-surface / inverse-primary
|
elements that contrast with the page: snackbars, plain tooltips |
primary-fixed … on-tertiary-fixed-variant
|
the same in light and dark, for brand moments that shouldn't flip |
shadow, scrim
|
elevation shadows and the dim layer behind modals |
Light and dark: how the switch works
Three CSS selectors decide which values are live. Light is the unconditional default. An explicit data-theme on the html element (or any ancestor) always wins. A prefers-color-scheme media query is the fallback used only when no explicit choice has been made.
Flip this whole page
Show code
/* phoenix_paper.css, simplified. Your overrides mirror this shape. */
/* 1. Light: the unconditional default. */
:root {
--color-pp-primary: #6750a4;
/* ... */
}
/* 2. Dark: an explicit choice. The toggle sets data-theme="dark". */
[data-theme="dark"] {
--color-pp-primary: #d0bcff;
/* ... */
}
/* 3. Dark: the OS preference, used only when nothing is set yet.
Same values as block 2. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-pp-primary: #d0bcff;
/* ... */
}
}
Pairing foregrounds
Every role has an on- counterpart for text and icons drawn on top of it (on-primary on primary, on-primary-container on primary-container). When you change a background role, change its on- role too, and check the contrast: at least 4.5:1 for body text, 3:1 for large text and icons. The Theme Creator shows the ratio for every pair.
Readable pair vs. a mismatch
on-primary on primary: correct
tertiary on primary: don't
Wiring the toggle
PhoenixPaper.ThemeToggle is a System / Light / Dark control, System by default: it removes data-theme, so the prefers-color-scheme fallback above picks the colors, and it only ever changes data-theme on click. Light and Dark are saved in localStorage under phx:theme; restore it in your root layout's head before first paint (a Phoenix 1.8 layout already does) and there's no flash. Point target at html (the default) for the whole page, or at a scoped selector to theme a preview pane.
Try it (it shares data-theme with this site's theme picker)
Show code
<%!-- Anywhere: a top app bar action, a settings panel. Defaults to target="html". --%>
<.pp_theme_toggle />
<%!-- root.html.heex <head>: restore a saved Light/Dark before first paint
(a Phoenix 1.8 root layout already ships this) --%>
<script>
(() => {
const theme = localStorage.getItem("phx:theme");
if (theme) document.documentElement.setAttribute("data-theme", theme);
})();
</script>
<%!-- Scoped preview, and saving the choice server-side too --%>
<div id="preview">
<.pp_theme_toggle target="#preview" on_toggle={JS.push("save_theme")} />
</div>
Checklist
Before you ship a custom theme:
:root
(light) and a
[data-theme="dark"]
block. Add the
@media (prefers-color-scheme: dark)
fallback (with the
:not([data-theme="light"])
guard) only if the page can render with no
data-theme
and should follow the OS.
data-theme
on first paint: hardcode one in root.html.heex, or the two-block setup shows light
until the toggle is clicked.
data-theme
is read (usually
html
in root.html.heex).