Guide

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

Primary
primary

on-primary text

primary-container

on-primary-container text

Secondary
secondary

on-secondary text

secondary-container

on-secondary-container text

Tertiary
tertiary

on-tertiary text

tertiary-container

on-tertiary-container text

Error
error

on-error text

error-container

on-error-container text

Surfaces
surface-container-lowest
surface-container-low
surface-container
surface-container-high
surface-container-highest
inverse-surface
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

No attribute Light, unless the OS prefers dark, in which case the media-query block applies.
data-theme="dark" Dark, always, whatever the OS says.
data-theme="light" Light, always: the :not([data-theme="light"]) guard on the media query is what lets an explicit light choice override a dark OS.
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:

Overrides live in your app.css, after the phoenix_paper import, never in the dep.
Two blocks: :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.
The page has a data-theme on first paint: hardcode one in root.html.heex, or the two-block setup shows light until the toggle is clicked.
Every background token you change gets its on- token changed and contrast-checked.
The toggle target matches where data-theme is read (usually html in root.html.heex).
Test both explicit choices, plus the no-attribute states on a light and a dark OS if you keep the fallback.

z7ealth 2026