{"id":"prz4i64f8ndmmjz","title":"Font Loading Without Layout Shift or Fake Bold","slug":"font-loading-without-layout-shift","summary":"Web fonts sell brand and steal stability. I limit families, subset glyphs, and match metrics so CLS stays quiet on mobile.","imageUrl":"https://briancrabtree.me/images/journal-font-loading-without-layout-shift.webp","category":"Performance","date":"2026-02-16T18:00:00.000Z","featured":false,"likes":33,"author":"Brian Crabtree","content":"<h2>FOIT, FOUT, and what users feel</h2>\n\n<p>Flash of invisible text feels broken. Flash of unstyled text feels cheap. The middle path is size-adjusted fallbacks and limited families. I load one sans for body, one display for heroes, and stop there unless the brand contract demands more.</p>\n\n<p>Every weight you add is another round trip or another block in a variable font slice. Marketing loves nine weights; performance loves two.</p>\n\n<p>System font stacks for UI chrome with brand face on hero only is a pattern banks use. Users care that numbers in tables align, not that every label uses Gotham.</p>\n\n<pre><code>@font-face {\n  font-family: 'Space Grotesk';\n  src: url('/fonts/space-grotesk-400.woff2') format('woff2');\n  font-display: swap;\n  size-adjust: 100%;\n}</code></pre>\n\n<h2>font-display and preload discipline</h2>\n\n<p>font-display swap shows text fast but swaps metrics when the webfont lands, which bumps CLS if fallbacks do not match. optional avoids swap shift on slow networks at the cost of sometimes keeping system fonts. I pick swap only with matched metrics, optional on secondary accents.</p>\n\n<p>Preload only the file that styles the LCP headline. Preloading every woff2 in the folder competes with the hero image on the same connection.</p>\n\n<p>font-variation-settings for weight axis reduce separate files when variable font quality is good. I test tabular nums feature for pricing tables.</p>\n\n<h2>Subsetting and formats</h2>\n\n<p>Latin subset for English sites cuts bytes dramatically. WOFF2 is the delivery format; keep TTF masters offline. Variable fonts help when you need many weights; static files win when you need one weight once.</p>\n\n<p>Self-host when licensing allows. Google Fonts is convenient but adds DNS, connection, and CSS indirection unless you self-host the files you already downloaded.</p>\n\n<p>Blocking @import chains in CSS from old themes still happen. I replace with link tags in head or bundled imports.</p>\n\n<h2>Metric overrides that work</h2>\n\n<p>size-adjust, ascent-override, and descent-override on fallback @font-face definitions narrow the gap between Arial and your brand face. Tools like fallback font generators give starting numbers; I tune against screenshots of the hero at mobile width.</p>\n\n<p>This site preloads one critical face on mobile journal shells and keeps body on system stack where brand allows. Discipline over decoration.</p>\n\n<p>Cumulative layout shift from cookie banners pushing content is adjacent to font CLS. I reserve banner height or use overlay consent when legal allows.</p>\n\n<h2>Third-party font kits</h2>\n\n<p>Adobe Fonts and similar kits add latency and sometimes block render. I audit what each kit injects. If it is two families, self-host equivalents and delete the kit.</p>\n\n<p>Icon fonts are legacy weight. SVG sprites or inline paths remove an entire failure mode and improve screen reader behavior.</p>\n\n<p>WOFF2 dual-loading from npm package and CDN duplicate is common after rushed migrations. Network panel filtered by font shows it immediately.</p>\n\n<h2>CLS interaction with components</h2>\n\n<p>React hydration that changes font class on the root after mount is a CLS trap. Define font-family in static CSS or prerendered HTML, not in useEffect toggles. Modals that load new weights when opened should reserve space or use already-loaded faces.</p>\n\n<p>Line-height and letter-spacing tokens should be defined alongside font-size tokens. Changing face without adjusting line-height recreates CLS.</p>\n\n<p>When I audit performance stacks, CLS interaction with components is where hours disappear if nobody owns the outcome. I write before-and-after notes, attach PSI or waterfall screenshots, and leave the team a three-item follow-up list. Reproducible wins beat toolchain debates in sprint review. If this section matches your bottleneck, send the URL and whether LCP, INP, CLS, or crawl coverage is the pain point.</p>\n\n<h2>Checklist I run</h2>\n\n<p>Count font files on network waterfall for mobile. If more than two compete with LCP, cut or defer. Match fallbacks, remeasure CLS, link PSI. Fonts are performance assets, not free branding.</p>\n\n<p>If brand requires a licensed face, budget the license and the bytes together. Cheap brand with expensive fonts is a product mismatch.</p>\n\n<p>When I audit performance stacks, Checklist I run is where hours disappear if nobody owns the outcome. I write before-and-after notes, attach PSI or waterfall screenshots, and leave the team a three-item follow-up list. Reproducible wins beat toolchain debates in sprint review. If this section matches your bottleneck, send the URL and whether LCP, INP, CLS, or crawl coverage is the pain point. For a related angle I keep coming back to, see <a href=\"/journal/interaction-to-next-paint-inp-guide/\">Interaction to Next Paint: What INP Measures and How I Fix It</a>.</p>","tags":["fonts","cls","performance"],"views":96}