Reducing layout shift
Use font metrics to build a fallback that takes up the same space as the real font.
In most cases, a webfont arrives after the first paint, so text is first set in whatever comes next in the stack. If that family is a different size, the page reflows when the real font lands. fontaine solves that issue: it generates a @font-face rule that points at a locally installed font and scales it with size-adjust, ascent-override, descent-override and line-gap-override until it occupies the same space as the real one.
unifont and fontaine are designed to work together. unifont resolves the location of the font and its descriptors; fontaine turns those into the fallback rule.
Let an integration do it
If you want fonts to work rather than to wire this up yourself, use an integration. Each of these resolves with unifont and generates the fallback ahead of time, so nothing runs in the browser.
In any Vite app (React, Svelte, Solid, Vue), add fontless:
// vite.config.ts
import react from '@vitejs/plugin-react'
import { fontless } from 'fontless'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), fontless()],
})Then name a family in your CSS. The plugin scans for it, resolves it through unifont, and injects the @font-face rules and the metric-matched fallback:
h1 {
font-family: "Poppins", sans-serif;
}In Nuxt, @nuxt/fonts does the same thing. You can enable it like this:
npx nuxt add fontsIf you'd rather not add a plugin at all, Leturgerð is a CLI that downloads the files once and writes the @font-face rules and the fallback into your project:
npx @leturgero/cli@latestThe result is plain font and CSS files that you commit, so it works with any tool rather than one bundler.
Build it yourself
If you're generating the CSS yourself, there are three steps: resolve the family, read the metrics of the file you're going to serve, and generate the fallback face.
import { generateFontFace, readMetrics } from 'fontaine'
import { createUnifont, providers } from 'unifont'
const unifont = await createUnifont([providers.google()])
const { fonts, fallbacks } = await unifont.resolveFont('Poppins')
const url = fonts
.flatMap(font => font.src)
.find(source => 'url' in source)?.url
const metrics = url ? await readMetrics(url) : null
const css = metrics
? generateFontFace(metrics, { name: 'Poppins fallback', font: fallbacks[0] ?? 'sans-serif' })
: ''generateFontFace() expands the generic family for you: local("Arial"), local("Arimo"), … for sans-serif, so the rule matches something on most platforms. resolveFont() returns fallbacks for this reason. It's the generic the provider reports as closest to the family.
Put the fallback between the real family and the generic, so text is set in the adjusted face until the webfont loads:
h1 {
font-family: "Poppins", "Poppins fallback", sans-serif;
}Which metrics to use
readMetrics() downloads the font file and measures it, which costs a request per family. Two cheaper sources exist, with a tradeoff each:
getMetricsForFamily(family)fromfontainelooks the name up in a bundled dataset. No network, but the dataset covers the family as published, which may differ from the cut a provider serves.- The
metricson a resolvedFontFaceData, where the provider reports them. No network and no guessing, but few providers do, and the set is partial:generateFontFace()needsxWidthAvgto computesize-adjust, and a provider that omits it leaves the fallback unscaled.
Whichever you pick, cache the result. Metrics for a given URL never change, so they belong in the same storage as the rest of your font data.
When a fallback won't help
The adjusted face is built on local(), so it only applies if one of the named fonts is installed. Where nothing matches, the browser skips the rule and falls through to the generic, unadjusted. That's the usual outcome on a machine with no Microsoft or Apple system fonts, so treat the fallback as an improvement on most visits rather than a guarantee on all of them.
Next steps
- Resolving fonts covers the options that control which faces you get back.
- Caching covers where to keep resolved metadata between builds.