# fetch once, commit the files (Phoenix static_paths already serves /fonts;
# mix phx.digest fingerprints them; italics render synthetic)
curl -o priv/static/fonts/fraunces-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/files/fraunces-latin-wght-normal.woff2
curl -o priv/static/fonts/inter-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5.3.0/files/inter-latin-wght-normal.woff2
curl -o priv/static/fonts/jetbrains-mono-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/jetbrains-mono@5.3.0/files/jetbrains-mono-latin-wght-normal.woff2
/* assets/css/app.css */
@font-face {
font-family: "Fraunces";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/fraunces-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/inter-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("/fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
}
/* body -> --font-sans reskins the whole app via preflight;
mono -> --font-mono reaches every pc mono surface */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
:root {
--pc-font-heading: "Fraunces", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
/* optional: raw h1-h6 outside <.h1>..<.h5> follow the heading face too */
h1, h2, h3, h4, h5, h6 {
font-family: var(--pc-font-heading, inherit);
}
<%!-- root.html.heex, before the CSS link - preload the body face only --%>
<link rel="preload" href={~p"/fonts/inter-latin-wght-normal.woff2"} as="font" type="font/woff2" crossorigin="anonymous" />
Hand it to your agent
Set up petal_components in this Phoenix + Tailwind v4 app with the look I dialed in at http://playground.petal.build/c/typography?heading=fraunces&body=inter&mono=jetbrains-mono
1. Install petal_components per https://petal.build/petal-components/rules.md (hex dep, the @source/@import lines in assets/css/app.css, `use PetalComponents` in the web module, spread the bundled JS hooks).
2. Add this to assets/css/app.css:
:root { color-scheme: light; }
.dark { color-scheme: dark; }
@theme inline {
--color-primary-50: light-dark(var(--color-gray-50), var(--color-gray-900));
--color-primary-100: light-dark(var(--color-gray-100), var(--color-gray-700));
--color-primary-200: light-dark(var(--color-gray-200), var(--color-gray-800));
--color-primary-300: light-dark(var(--color-gray-300), var(--color-gray-300));
--color-primary-400: light-dark(var(--color-gray-400), var(--color-gray-600));
--color-primary-500: light-dark(var(--color-gray-600), var(--color-gray-400));
--color-primary-600: light-dark(var(--color-gray-900), var(--color-gray-50));
--color-primary-700: light-dark(var(--color-gray-800), var(--color-gray-200));
--color-primary-800: light-dark(var(--color-gray-700), var(--color-gray-300));
--color-primary-900: light-dark(var(--color-gray-900), var(--color-gray-100));
--color-primary-950: light-dark(var(--color-gray-950), var(--color-gray-50));
}
:root {
--pc-button-solid-fg: light-dark(#ffffff, var(--color-gray-900));
}
3. Self-host these fonts:
# fetch once, commit the files (Phoenix static_paths already serves /fonts;
# mix phx.digest fingerprints them; italics render synthetic)
curl -o priv/static/fonts/fraunces-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/files/fraunces-latin-wght-normal.woff2
curl -o priv/static/fonts/inter-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5.3.0/files/inter-latin-wght-normal.woff2
curl -o priv/static/fonts/jetbrains-mono-latin-wght-normal.woff2 \
https://cdn.jsdelivr.net/npm/@fontsource-variable/jetbrains-mono@5.3.0/files/jetbrains-mono-latin-wght-normal.woff2
/* assets/css/app.css */
@font-face {
font-family: "Fraunces";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/fraunces-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/inter-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url("/fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
}
/* body -> --font-sans reskins the whole app via preflight;
mono -> --font-mono reaches every pc mono surface */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
:root {
--pc-font-heading: "Fraunces", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
/* optional: raw h1-h6 outside <.h1>..<.h5> follow the heading face too */
h1, h2, h3, h4, h5, h6 {
font-family: var(--pc-font-heading, inherit);
}
<%!-- root.html.heex, before the CSS link - preload the body face only --%>
<link rel="preload" href={~p"/fonts/inter-latin-wght-normal.woff2"} as="font" type="font/woff2" crossorigin="anonymous" />
Then start the server and confirm headings, body text and code render in the chosen look.
Typography
The refined 4.2 scale: self-composing vertical rhythm, balanced
headings, and a three-tier emphasis system that holds in both modes.
Type tokens
Everything on this page follows the type
dials in the strip above: three opt-in tokens
(--pc-font-heading, --pc-font-body,
--pc-font-mono) that the library reads but never defines,
so an app that sets nothing keeps its own stack. Heading falls back through body,
so one body token restyles everything.
Dial in a look, then share the URL - the theme travels in the query string.
Headings
The quick brown fox
The quick brown fox
The quick brown fox
The quick brown fox
The quick brown fox
Body and emphasis tiers
A lead paragraph sits between heading and body - one size up, muted a step.
Default body copy carries the middle emphasis tier. It pairs
inline_code
with strong text
at the top tier, and stays readable
across light and dark without per-mode overrides.
Muted text is the quiet tier - captions, hints, timestamps.