I wanted font swaps that don’t move the page. The setup is ordinary: a web font with font-display: swap, so text stays readable in a fallback while the real font loads. The cost of swap is the reflow when the web font arrives and its metrics differ from the fallback’s. The fix is also ordinary: a metric-matched fallback @font-face built on a local() system font, with size-adjust, ascent-override, descent-override, and line-gap-override tuned so the fallback takes up the same space as the web font. No layout shift on swap.
I wanted that fallback face to live in theme.json with the rest of the type config, so the theme stays declarative. So I wrote it into theme.json myself, as a second fontFace entry with a local() src next to the real file-based one:
{
"fontFamily": "Gabarito Fallback",
"fontStyle": "normal",
"sizeAdjust": "96%",
"ascentOverride": "95%",
"src": [ "local("Arial")" ]
}
The fallback stopped working. When I dumped the generated @font-face CSS, the local() source had been stripped out. The override descriptors were there; the source was not. The web font face right beside it emitted fine, and there was no error anywhere. One thing to rule out: this is not core rewriting my src. In the same output, the file-based face emitted exactly the url() I gave it, with nothing added. It is the author-supplied local() specifically that gets dropped.
So I read the source. The class that prints @font-face blocks is WP_Font_Face, in wp-includes/fonts/class-wp-font-face.php, and the method that matters is order_src(). I’m reading the copy that ships in WordPress 7.0.2; line numbers move between releases, so go by the method name. For each source it does this:
$format = pathinfo( $url, PATHINFO_EXTENSION );
$src[ $format ] = $url;
It keys every source by file extension. Then it re-emits, in order, only the entries it finds under woff2, woff, ttf, eot, and otf, plus data: URIs, which it handles first. A local("Arial") string has no font-file extension, so it lands under an empty key that nothing reads back. The override descriptors are valid properties and pass through, but the source is gone, so the fallback face has nothing to load.
This is not a bug to file. Core’s font printer supports URL file-format faces by design; local() was never part of that contract. So local() is out of scope on purpose, and theme.json font faces are for files you host.
The right place for a local() metric-fallback face is CSS I emit myself. I compute the metrics at build time and print the faces in wp_head:
// built at render time, printed in a <style> tag in wp_head
$css .= sprintf(
'@font-face{font-family:"%s Fallback";font-style:normal;src:local("%s");size-adjust:%s;ascent-override:%s;descent-override:%s;line-gap-override:%s;font-display:swap;}',
esc_attr( $name ),
esc_attr( $m['local'] ),
esc_attr( $m['sizeAdjust'] ),
esc_attr( $m['ascentOverride'] ),
esc_attr( $m['descentOverride'] ),
esc_attr( $m['lineGapOverride'] )
);
Then the family stack names the fallback between the web font and the generic keyword: "Gabarito", "Gabarito Fallback", sans-serif. The web font faces stay in theme.json where they belong. The one face theme.json can’t express lives in a small PHP function.
That’s one finding from one build. If you reach for a local() src in theme.json and it disappears, this is why, and here is where to look.

