Typography and Fonts
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
A block theme declares its fonts and its type scale in theme.json, and WordPress turns those declarations into
@font-face rules, CSS custom properties and, when fluid typography is on, clamp() expressions. Site owners add more
fonts through the Font Library without touching the theme. This page covers both sides for theme developers: bundling
self-hosted WOFF2 files in the Lumen Press lumen theme, what WordPress prints from them, how a fluid size is
calculated, how the Font Library stores fonts and exposes them over REST, and how to keep fonts private and fast.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3 and WP-CLI 2.12.0, and checked
on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing
announced for it so far changes the font APIs described here. Every output on this page comes from a 7.1.3 site
(the wordpress:7.1.3-php8.4-apache image with MariaDB 11.8) running the lumen theme below.
The general theme.json settings (appearanceTools, layout, colour, spacing) are covered in
theme.json Settings, and how the settings become styles in
theme.json Styles. The site owner’s view of the Fonts screen and of
Styles > Typography is in Site Editor and Global Styles.
How fonts reach a page
Three layers can declare fonts, merged in the usual theme.json order (core < parent theme < child theme < user):
| Layer | Where it is declared | Where the files live |
|---|---|---|
Theme |
|
Inside the theme, referenced with |
Font Library (installed) |
|
|
User (activated) |
|
Points at the Font Library files |
The Font Face API (wp_print_font_faces(), WordPress 6.4) reads the merged theme.json data, collects every
fontFace entry, and prints one <style class="wp-fonts-local"> block. It is hooked to wp_head at priority 50 and
is also called for the editor canvas, so the editor and the front end use the same files. The fontFamily value of
each family becomes a preset custom property, --wp--preset--font-family--{slug}, which styles reference as
var:preset|font-family|<slug>.
A font that is installed in the Font Library but not activated is not part of the merged data, so no @font-face
rule is printed for it and no visitor downloads it.
Self-hosted fonts in theme.json
The Lumen Press theme uses Source Serif 4 (Adobe, SIL Open Font License 1.1) for body text and a system sans-serif
stack for headings. The variable WOFF2 files come from the font’s official release on GitHub
(source-serif-4.005_WOFF2.zip, folder VAR), renamed to source-serif-4-roman.woff2 and
source-serif-4-italic.woff2 and stored in lumen/assets/fonts/source-serif-4/ together with OFL.txt, because the
OFL requires the licence to travel with the font files. lumen/theme.json keeps
the palette and layout of Themes Overview and adds the typography settings
and styles; the styles section below shows only the typography styles:
{
"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"color": {
"palette": [
{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" }
]
},
"layout": { "contentSize": "680px", "wideSize": "1140px" },
"typography": {
"defaultFontSizes": false,
"fluid": {
"minViewportWidth": "360px",
"maxViewportWidth": "1200px",
"minFontSize": "15px"
},
"fontSizes": [
{ "slug": "small", "name": "Small", "size": "0.875rem", "fluid": false },
{ "slug": "medium", "name": "Medium", "size": "1.125rem", "fluid": { "min": "1rem", "max": "1.125rem" } },
{ "slug": "large", "name": "Large", "size": "1.75rem", "fluid": { "min": "1.375rem", "max": "1.75rem" } },
{ "slug": "x-large", "name": "Extra large", "size": "3rem" }
],
"fontFamilies": [
{
"slug": "source-serif",
"name": "Source Serif 4",
"fontFamily": "\"Source Serif 4\", Georgia, serif",
"fontFace": [
{
"fontFamily": "Source Serif 4",
"fontStyle": "normal",
"fontWeight": "200 900",
"fontDisplay": "swap",
"src": [ "file:./assets/fonts/source-serif-4/source-serif-4-roman.woff2" ]
},
{
"fontFamily": "Source Serif 4",
"fontStyle": "italic",
"fontWeight": "200 900",
"fontDisplay": "swap",
"src": [ "file:./assets/fonts/source-serif-4/source-serif-4-italic.woff2" ]
}
]
},
{
"slug": "system-sans",
"name": "System sans-serif",
"fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif"
}
]
}
},
"styles": {
"typography": {
"fontFamily": "var:preset|font-family|source-serif",
"fontSize": "var:preset|font-size|medium",
"lineHeight": "1.6"
},
"elements": {
"heading": {
"typography": { "fontFamily": "var:preset|font-family|system-sans", "fontWeight": "700" }
},
"h1": { "typography": { "fontSize": "var:preset|font-size|x-large" } }
}
}
}
The keys of a font family and a font face
| Key | Meaning |
|---|---|
|
Label shown in Styles > Typography and on the Fonts screen. |
|
Identifier used in the preset custom property and in |
|
The CSS |
|
Optional array of faces; each becomes one |
|
The family name the rule declares. It must match the first name in the family’s |
|
A string or an array of URLs. |
|
Descriptors of the face. A range ( |
|
|
|
The remaining |
settings.typography also holds the editor switches covered in
theme.json Settings: customFontSize, dropCap, fontStyle,
fontWeight, letterSpacing, lineHeight, textColumns, textDecoration, textTransform and writingMode
(appearanceTools turns on the ones that default to off).
The generated @font-face CSS
With the theme active, wp_print_font_faces() prints two rules, and the family values become preset custom
properties in the global stylesheet:
docker compose exec cli wp eval 'wp_print_font_faces();'
docker compose exec cli sh -c 'wp eval "echo wp_get_global_stylesheet( array( \"variables\" ) );" | tr ";" "\n" | grep font-family'
<style class="wp-fonts-local">
@font-face{font-family:"Source Serif 4";font-style:normal;font-weight:200 900;font-display:swap;src:url('http://lumenpress.test/wp-content/themes/lumen/assets/fonts/source-serif-4/source-serif-4-roman.woff2') format('woff2');}
@font-face{font-family:"Source Serif 4";font-style:italic;font-weight:200 900;font-display:swap;src:url('http://lumenpress.test/wp-content/themes/lumen/assets/fonts/source-serif-4/source-serif-4-italic.woff2') format('woff2');}
</style>
--wp--preset--font-family--source-serif: "Source Serif 4", Georgia, serif
--wp--preset--font-family--system-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif
The URLs are http:// because WP-CLI has no HTTPS request to detect; in a page served over HTTPS, is_ssl() is true
and the same rules use https://lumenpress.test/.... The system-stack family prints no rule.
Classic and hybrid themes
wp_print_font_faces() reads theme.json, so a classic theme that ships one (such as a hybrid lumen-classic) gets
the same output by declaring fontFamilies there. Without theme.json, a classic theme enqueues a stylesheet with
its own @font-face rules (see functions.php, Theme
Supports and Assets). Either way the files ship inside the theme.
Fluid typography
A fluid font size grows linearly with the viewport width between a minimum and a maximum. WordPress writes it as a CSS
clamp(min, preferred, max) whose middle term mixes rem and vw. Fluid typography arrived in WordPress 6.1, and
6.4 made the viewport bounds (minViewportWidth, maxViewportWidth) configurable.
Turning it on
| Setting | Effect |
|---|---|
|
Every preset size and every fixed size chosen in the editor becomes fluid, with the default bounds. |
|
Same, with |
|
That preset stays static (Lumen’s |
|
Explicit bounds for that preset. A preset with its own |
|
The preset |
defaultFontSizes (theme.json version 3 default true) controls whether core’s own size presets (small, medium,
large, x-large, xx-large) are output. Since version 3 a theme preset with a core slug no longer replaces core’s
when defaultFontSizes is true; Lumen sets it to false so that its four sizes are the only ones and keep core’s
slugs, which keeps content portable between themes.
The values WordPress computes
wp_get_typography_font_size_value() turns a preset (or any array( 'size' => ... )) into the value WordPress
outputs. A short wp eval prints it for Lumen’s presets and for three ad hoc sizes, as if an editor had typed them in
a block’s typography panel:
docker compose exec cli wp eval '
foreach ( wp_get_global_settings( array( "typography", "fontSizes", "theme" ) ) as $preset ) {
printf( "%-8s %s\n", $preset["slug"], wp_get_typography_font_size_value( $preset ) );
}
foreach ( array( "16px", "14px", "1.5rem" ) as $size ) {
printf( "%-8s %s\n", $size, wp_get_typography_font_size_value( array( "size" => $size ) ) );
}'
small 0.875rem
medium clamp(1rem, 1rem + ((1vw - 0.225rem) * 0.238), 1.125rem)
large clamp(1.375rem, 1.375rem + ((1vw - 0.225rem) * 0.714), 1.75rem)
x-large clamp(1.743rem, 1.743rem + ((1vw - 0.225rem) * 2.394), 3rem)
16px clamp(15px, 0.938rem + ((1vw - 3.6px) * 0.119), 16px)
14px 14px
1.5rem clamp(0.984rem, 0.984rem + ((1vw - 0.225rem) * 0.983), 1.5rem)
The same strings appear as --wp--preset--font-size--{slug} custom properties in the global stylesheet. These
functions have no Code Reference page; the source file above is the authoritative description.
The maths
The function wp_get_computed_fluid_typography_value() builds:
clamp( min, min_rem + ((1vw - vw_min / 100) * factor), max )
factor = 100 × (max − min) / (vw_max − vw_min)
All lengths are first converted to the unit of the minimum size (1rem = 16px for the conversion), and min_rem is the
minimum expressed in rem. Since 1vw is one hundredth of the viewport width W, the middle term equals
min + (W − vw_min) × (max − min) / (vw_max − vw_min): a straight line that passes through min at vw_min and
through max at vw_max. clamp() holds the value at the bounds outside that range. Starting the line from a rem
value keeps the text responsive to browser zoom and to the user’s default font size, which a pure vw size would
ignore (WCAG 1.4.4, Resize Text).
For Lumen’s x-large preset (3rem, no explicit minimum), step by step:
-
Minimum size. When no
minis given, WordPress scales the size byclamp(1 − 0.075 × log2(size in px), 0.25, 0.75). For 48px: log2(48) = 5.585, so the factor is 1 − 0.419 = 0.581, and 3rem × 0.581 = 1.743rem (27.9px). Larger sizes shrink more, smaller ones less. -
Minimum limit. The computed minimum may not fall below
minFontSize(15px here); 27.9px is above it. -
Viewport bounds in the font’s unit. 360px = 22.5rem and 1200px = 75rem, so the offset
vw_min / 100is 0.225rem. -
Factor. 100 × (3 − 1.743) / (75 − 22.5) = 125.7 / 52.5 = 2.394.
-
Check. At a 768px viewport, 1vw = 7.68px = 0.48rem: 1.743 + (0.48 − 0.225) × 2.394 = 2.354rem, about 37.7px. At 1200px, 1vw = 0.75rem and the result is exactly 3rem.
The other rows follow the same rules:
-
mediumhas explicit bounds, so the factor is 100 × (1.125 − 1) / 52.5 = 0.238. -
16pxgets a computed minimum of 16 × (1 − 0.075 × 4) = 11.2px, below the 15px limit, so the minimum is 15px. Because the minimum is in pixels, the offset is 3.6px; the middle term still starts at 15px written as 0.938rem. -
14pxis not larger thanminFontSizeand has no explicit bounds, so it stays static: shrinking it would make it unreadable. -
smallis static because of"fluid": false.
Pitfalls: units other than px, rem and em (such as pt, % or a calc() value) are returned unchanged;
minViewportWidth equal to maxViewportWidth disables the calculation; and a wideSize in % or vw cannot be used
as the maximum viewport, so the 1600px default applies. Setting maxViewportWidth explicitly avoids depending on the
layout width.
The Font Library
The Font Library (WordPress 6.5) lets users install fonts without editing the theme. In 6.5 it was a modal in
Styles > Typography of the Site Editor; since 7.0 it is also an admin page, Appearance > Fonts
(wp-admin/font-library.php), available with every theme, classic ones included. The page requires the
edit_theme_options capability (Administrators; Editors do not have it). Its tabs are Library, Upload (.woff2,
.woff, .ttf, .otf) and Install Fonts (font collections); the screens themselves are described in
Site Editor and Global
Styles.
Storage
| Item | Storage |
|---|---|
Font family |
A |
Font face |
A |
Files |
|
Activation |
An entry in |
Deleting a family deletes its faces, and deleting a face deletes its files (_wp_before_delete_font_face()). Font
posts belong to the site, not to the theme: they survive a theme switch and are not part of a theme export.
The REST endpoints
| Route | Methods | Purpose |
|---|---|---|
|
GET |
Registered collections, each with its font families and categories. |
|
GET |
One collection. For |
|
GET, POST |
Installed families. POST takes a |
|
GET, POST/PUT/PATCH, DELETE |
One family; DELETE needs |
|
GET, POST |
Faces of a family. POST takes |
|
GET, DELETE |
One face; DELETE needs |
Every route requires edit_theme_options; an anonymous request gets 401 rest_cannot_read. The following session
installs Inter (rsms/inter 4.1, OFL, InterVariable.woff2 from its GitHub release) as a user font, authenticated
with an application password stored in WP_APP_PASSWORD (application passwords need HTTPS, or
WP_ENVIRONMENT_TYPE set to local, as explained in REST API: Using It):
SITE=https://lumenpress.test/wp-json
AUTH="admin:$WP_APP_PASSWORD"
# 1. Create the family.
curl -s -u "$AUTH" -X POST "$SITE/wp/v2/font-families" \
-F 'font_family_settings={"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif"}'
# 2. Upload one variable face to family 4 (the id returned by step 1).
curl -s -u "$AUTH" -X POST "$SITE/wp/v2/font-families/4/font-faces" \
-F 'font_face_settings={"fontFamily":"Inter","fontStyle":"normal","fontWeight":"100 900","src":"inter-file"}' \
-F 'inter-file=@inter-variable.woff2;type=font/woff2'
# 3. List the installed families.
curl -s -u "$AUTH" "$SITE/wp/v2/font-families?_fields=id,font_family_settings,font_faces"
The responses, without their _links:
{"id":4,"theme_json_version":3,"font_faces":[],"font_family_settings":{"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif","preview":""}}
{"id":5,"theme_json_version":3,"parent":4,"font_face_settings":{"fontFamily":"Inter","fontStyle":"normal","fontWeight":"100 900","src":"https:\/\/lumenpress.test\/wp-content\/uploads\/fonts\/inter-variable.woff2"}}
[{"id":4,"font_faces":[5],"font_family_settings":{"name":"Inter","slug":"inter","fontFamily":"Inter, sans-serif","preview":""}}]
The stored rows and file:
docker compose exec cli wp post list --post_type=wp_font_family,wp_font_face --fields=ID,post_type,post_title,post_parent
docker compose exec cli wp post meta list 5
docker compose exec cli ls wp-content/uploads/fonts
Source: WP-CLI: wp post list
ID post_type post_title post_parent
5 wp_font_face inter;normal;100 900;100%;U+0-10FFFF 4
4 wp_font_family Inter 0
post_id meta_key meta_value
5 _wp_font_face_file inter-variable.woff2
inter-variable.woff2
At this point wp_print_font_faces() still prints only the two Source Serif rules: Inter is installed, not active.
The Fonts screen activates it by writing the family into the user’s global styles, which the same REST API does with
POST /wp/v2/global-styles/<id> (the id of the theme’s wp_global_styles post) and this body, which also uses Inter
for headings:
{
"settings": { "typography": { "fontFamilies": { "custom": [ {
"name": "Inter", "slug": "inter", "fontFamily": "Inter, sans-serif",
"fontFace": [ { "fontFamily": "Inter", "fontStyle": "normal", "fontWeight": "100 900",
"src": "https://lumenpress.test/wp-content/uploads/fonts/inter-variable.woff2" } ]
} ] } } },
"styles": { "elements": { "heading": { "typography": { "fontFamily": "var:preset|font-family|inter" } } } }
}
Source: REST API Handbook: Global Styles
Afterwards the front page’s <head> carries a third rule, with the default font-display: fallback because the face
has no fontDisplay:
@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:fallback;src:url('https://lumenpress.test/wp-content/uploads/fonts/inter-variable.woff2') format('woff2');}
Errors met on the way, as returned by 7.1.3:
| Response | Cause |
|---|---|
|
No authentication, or a user without |
|
An installed family already uses that slug. A user family may share a slug with a theme family; the user one wins when activated. |
|
The file is not |
|
DELETE without |
Font names are sanitized on the way in: a fontFamily of Source Serif 4 is stored as "Source Serif 4", quoted
because of the spaces.
Font collections and Google Fonts
A font collection is a catalogue shown on the Install Fonts tab. Core registers one, google-fonts, whose
catalogue is a JSON file on s.w.org (google-fonts-with-preview.json for 7.1). Plugins add their own with
wp_register_font_collection() and remove one with wp_unregister_font_collection(), both on init.
Installing from Google Fonts is opt-in. The tab first asks for permission to connect to Google’s servers (stored in
the browser, revocable from the tab’s menu). After that, the administrator’s browser downloads the chosen files
from Google and uploads them to /wp/v2/font-families/<id>/font-faces, exactly like the Inter upload above. Visitors
receive the files from the site’s own domain and never contact Google.
That is the correction to older books, Pro WordPress in particular, which load Google Fonts by enqueueing a
fonts.googleapis.com stylesheet. Loading from the CDN sends every visitor’s IP address and user agent to a third
party, which under the GDPR needs a legal basis and usually a consent banner; it also adds a DNS lookup and a
connection to another origin before text can render. Self-hosting (bundled fontFace files in the theme, or the Font
Library) removes both problems. Google Fonts files are under the OFL or the Apache License, so redistributing them
inside a theme is allowed; for the Lumen theme they are taken from the font project’s own release rather than from
the Google Fonts API, which serves subsets tuned per browser.
Restricting the Font Library
On production, Lumen Press lets only the theme decide which fonts exist. A must-use plugin removes the Google Fonts collection and hides the Font Library UI in the editors:
<?php
/**
* Plugin Name: Lumen Fonts Policy
* Description: Removes the Google Fonts collection and hides the Font Library UI on the Lumen Press production site.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 7.4
*/
add_action(
'init',
static function () {
wp_unregister_font_collection( 'google-fonts' );
}
);
add_filter(
'block_editor_settings_all',
static function ( $settings ) {
$settings['fontLibraryEnabled'] = false;
return $settings;
}
);
Saved as wp-content/mu-plugins/lumen-fonts-policy.php, GET /wp/v2/font-collections returns [] and the editor
settings carry fontLibraryEnabled: false. The filter hides the Font Library inside the editors only; the REST routes
and Appearance > Fonts remain for users with edit_theme_options. To close the API as well, the 6.5 dev note sets
show_in_rest to false for wp_font_family and wp_font_face through register_post_type_args, and removes the
/wp/v2/font-collections routes with rest_endpoints.
Fonts are writable even when DISALLOW_FILE_MODS is true, because the files go to the uploads directory like media.
Uploaded font files are binary data that the browser parses, so only trusted administrators should hold
edit_theme_options; see Security Hardening.
Variable fonts
A variable font holds a continuous range of one or more axes in a single file. Read from the fonts' fvar
tables, the roman Source Serif 4 file (427 KB) has wght 200–900 and opsz 8–60, and InterVariable.woff2 (352 KB)
has opsz 14–32 and wght 100–900.
In theme.json a variable face declares its range in the descriptor ("fontWeight": "200 900", or
"fontStretch": "75% 125%" for a width axis), so every weight from the editor’s weight picker resolves to the same
file and the browser renders the exact weight instead of synthesizing bold. The optical-size axis needs nothing:
font-optical-sizing: auto is the browser default and picks the opsz value from the font size. Custom axes or
fixed axis values go in fontVariationSettings on the face, or in font-variation-settings in block styles.
Two variable files (roman and italic) replace the eight or more static files an older theme would ship for regular, medium, semibold and bold in both styles. A static file is still smaller when a site truly needs one weight.
Font performance
-
WOFF2 only. Every browser WordPress supports reads WOFF2, which is Brotli-compressed. The "bulletproof"
@font-facestacks with EOT, SVG, TTF and WOFF fallbacks in older tutorials are unnecessary. -
Subset. The full Source Serif 4 roman covers many scripts. A site that publishes only in Western European languages can subset the file (for example with
pyftsubsetfrom fontTools) and declare the covered range inunicodeRange, so the browser skips the file for pages without those characters. Keep the OFL notice; the licence allows modified versions under a different name when the Reserved Font Name applies. -
font-display.swapshows fallback text at once and swaps when the file arrives (good for body text);fallback, WordPress’s default, swaps only if the file arrives within about three seconds;optionalnever swaps after first paint. Pairswapwith a fallback whose metrics are close (sizeAdjust,ascentOverride) to limit layout shift. -
Preload the critical face. Only the upright body face is used above the fold on every Lumen page; preloading it lets the browser fetch it in parallel with the CSS. WordPress does not preload fonts itself, so the theme does:
<?php
/**
* Lumen theme functions.
*
* @package lumen
*/
/**
* Preloads the upright body font, which every page uses above the fold.
*/
function lumen_preload_body_font() {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
esc_url( get_theme_file_uri( 'assets/fonts/source-serif-4/source-serif-4-roman.woff2' ) )
);
}
add_action( 'wp_head', 'lumen_preload_body_font', 1 );
The crossorigin attribute is required even for same-origin fonts (fonts are always fetched in CORS mode); without
it the preloaded file is not reused and is downloaded twice. Preload one or two files at most: each preload competes
with the CSS and the largest image for bandwidth.
-
Cache. Font files never change under the same name, so the web server can send them with a long
Cache-Controllifetime andimmutable; when a font is updated, rename the file. Server configuration is in Web Servers: Apache and nginx and caching in general in Performance and Caching. -
Count the families. Each activated family with faces adds files to every page. A typography style variation that swaps the font pair (see Style Variations and Section Styles) loads only the fonts of the active variation on the front end.
What older books and tutorials get wrong
| Older advice | Current practice (WordPress 7.1) |
|---|---|
Load Google Fonts by enqueueing a |
Self-host: |
Install a fonts plugin to add custom or Google fonts (older site-owner guides) |
The Font Library is core since 6.5, and since 7.0 Appearance > Fonts works with classic themes as well. |
Set heading sizes in |
Font size presets with fluid typography produce one |
|
Declare fonts in |
Ship EOT, SVG, TTF and WOFF for each weight |
One variable WOFF2 per style, with a weight range. |
Custom fonts are stored in |
They are stored in |