# fontaine: giving a system fallback the metrics of your web font

> fontaine reads the @font-face rules your build already processes, measures a local fallback font against your web font, and writes a CSS only metric override so the late swap stops moving text. The payoff is a layout shift drop from 0.24 to 0.054 on the project's playground page, paid for with manual wiring wherever your CSS is preprocessed or hidden behind variables.

**unjs/fontaine** — Automatic font fallback based on font metrics

- Repository: https://github.com/unjs/fontaine
- Stars: 2,002 · Forks: 51
- Language: TypeScript
- License: MIT
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/unjs-fontaine

## Crafted metric overrides for fonts that arrive late

A page that loads a web font paints the fallback first and swaps in the real font when it lands. Because the two faces rarely share an ascent, a descent or a line gap, that swap moves text and shoves whatever sits below it down the page. `fontaine` targets that one reflow: it reads the `@font-face` rules in your CSS, measures a fallback family that already lives on the reader's machine, and emits an extra family name whose metrics line up with the web font. Browsers then lay out the fallback as if it were the real thing, so the arrival costs nothing on screen.

All of it happens at build time. The transform ships as a plugin for Vite, for webpack, or for PostCSS, and what it writes is plain CSS with no JavaScript handed to the browser, which is the stated goal of pure CSS and zero runtime overhead. On the repository's own playground page rendering `/` with no customisation, Cumulative Layout Score moves from 0.24 to 0.054 while the Performance score goes from 92 to 100.

The audience is narrow and identifiable: sites that self host a font file, already reference it through `@font-face`, and can name the system fonts most of their readers have sitting on disk already. A site that loads fonts from a third party host and never sees an `@font-face` source in its own CSS hands the transform nothing to read.

## Three package managers, one dev dependency to add

`fontaine` is a build time dependency rather than something a component imports. The install is the same shape in all three common runners, and the `-D` flag keeps it out of your production tree:

```bash
pnpm add -D fontaine
npm install -D fontaine
yarn add -D fontaine
```

What lands in the project is one exported transform object. A Vite project pushes it into the plugin array, and that array is where the first visible output appears, the extra `@font-face` rules that pair each fallback family with copied metrics:

```js
// Vite
export default {
  plugins: [FontaineTransform.vite(options)]
}
```

The name `FontaineTransform` comes from `import { FontaineTransform } from 'fontaine'`, and `options` is where the fallback chain and asset resolution live. Nuxt is the exception: the project points you at `nuxt-font-metrics`, which wraps `fontaine` under the hood, so the transform reaches your build through a module you never write yourself.

## One options object across Vite, webpack and PostCSS

The same options object serves every integration, which is the part worth planning around:

```js
const options = {
  // You can specify fallbacks as an array (applies to all fonts)
  fallbacks: ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],

  // Or as an object to configure specific fallbacks per font family
  // fallbacks: {
  //   Poppins: ['Helvetica Neue'],
  //   'JetBrains Mono': ['Courier New']
  // },

  // You may need to resolve assets like `/fonts/Roboto.woff2` to a particular directory
  resolvePath: id => `file:///path/to/public/dir${id}`,
```

An array of families applies to all fonts. An object keys the chain per font family, so Poppins can take `Helvetica Neue` while `JetBrains Mono` takes `Courier New`. `resolvePath` exists because the src value in CSS is only a relative string, and the example maps it onto a real file under a public directory so the metrics can be read off disk. `fallbackName` renames the generated family, `sourcemap: false` is the default, and `skipFontFaceGeneration` receives a fallback name and lets you suppress the rule for that one.

The same object feeds the webpack wrapper, which is how Next.js and Gatsby reach it. Next.js needs it pushed into `config.plugins` from inside your `webpack` hook:

```js
// Next.js
export default {
  webpack(config) {
    config.plugins = config.plugins || []
    config.plugins.push(FontaineTransform.webpack(options))
    return config
  },
}
```

Docusaurus gets a hand written plugin that forwards the identical wrapper:

```js
function fontainePlugin(_context, _options) {
  return {
    name: 'fontaine-plugin',
    configureWebpack(_config, _isServer) {
      return {
        plugins: [
          fontaine.FontaineTransform.webpack(options),
        ],
      }
    },
  }
}
```

One detail in that snippet catches people copying it. The plugin function declares `_context` and `_options`, both prefixed so they read as unused, yet the body pushes `FontaineTransform.webpack(options)` using the outer `options` constant from the surrounding module. Settings placed in the Docusaurus plugin options object are therefore ignored, and that outer constant has to exist at module scope for the call to resolve at all. Gatsby follows the same pattern in `gatsby-node.js`, registering the wrapper inside `onCreateWebpackConfig`.

## Sass partials never reach the bundler plugin

Ordering is where most integrations quietly fail. The bundler plugin runs before your preprocessor, so it only ever sees the raw source of files the bundler treats as modules. A `@font-face` rule living in a Sass partial, produced by a mixin, or pulled in with a Sass `@import` never reaches it, because Sass inlines all of that first. The same holds for Less and Stylus.

The remedy is the second entry point, a PostCSS plugin that runs over fully compiled CSS, where `@font-face` rules and `font-family` values are already plain text by the time it reads them:

```js
// postcss.config.js
module.exports = {
  plugins: [
    require('fontaine/postcss')({
      fallbacks: ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],
    }),
    // ...any other plugins, such as autoprefixer or cssnano
  ],
}
```

It accepts the same `fallbacks`, `categoryFallbacks`, `resolvePath`, `skipFontFaceGeneration` and `fallbackName` options as the bundler plugin, so migrating means moving one object between two registration sites rather than rewriting configuration. The guidance on running both is a flat prohibition with no explanation of the conflict: do not use both plugins at once, and nothing describes what two plugins writing the same fallback family name into one stylesheet should produce.

## The PostCSS plugin appends fallbacks it never generated

Here is the behaviour that is easy to skim past. A `font-family` declaration and the `@font-face` rule it points at can end up in different stylesheets, so the PostCSS plugin appends a fallback to every non generic `font-family` it finds, whether or not it generated the matching `@font-face` rule itself. A fallback family with no rule behind it is ignored by the browser, so dangling entries cost nothing in output. They still describe the design honestly: the promise is not that the plugin generates exactly what it needs and leaves the rest alone. It appends everywhere, then trusts the browser to discard the extras.

Webpack plus Sass needs two settings for that pipeline to function. Source maps have to be enabled on `sass-loader`, since Sass does not rebase the URLs it inlines and the source map is how the plugin works out which stylesheet a font URL was written relative to; without it, relative font paths resolve against the entrypoint and metrics cannot be read at all. Separately, `importLoaders` on `css-loader` has to be set to 2 for `postcss-loader` plus `sass-loader`, otherwise imported CSS holding `@font-face` rules, Fontsource packages for instance, is resolved by `css-loader` without passing through `postcss-loader` and the plugin never encounters those rules.

## CSS variables need the literal fallback suffix

A third failure mode comes from indirection. Docusaurus, for one, points `--ifm-font-family-base` at a custom font, and a variable hides that name from anything scanning the stylesheet. The repair is manual and literal: append a family name built from the font name plus the word fallback, so the generated `@font-face` rule becomes reachable through the variable.

```diff
:root {
  /* ... */
-  --ifm-font-family-base: 'Poppins';
+  --ifm-font-family-base: 'Poppins', 'Poppins fallback';
```

Behind that line sits a `@font-face` rule the transform has already created under the name `Poppins fallback`. Naming it in the variable is what makes the browser use that rule and its generated metrics instead of falling through to whatever the system supplies. Nothing infers this on your behalf, so every variable pointing at a custom font needs the same edit, and the suffix has to match the font name exactly or the browser looks for a family that was never declared.

## Category defaults switch on with an empty object

Automatic fallback selection is opt in through configuration rather than a default. Passing `fallbacks` as an array, the form most projects begin with, means every font gets that one chain, including a monospace face handed a serif fallback. Passing an empty object turns on selection by category, so serif, sans serif, monospace, display and handwriting each draw from their own list:

```js
const options = {
  // Use an empty object to enable automatic category-based fallbacks
  fallbacks: {},

  // Or customize specific categories while keeping defaults for others
  categoryFallbacks: {
    'serif': ['Georgia', 'Times New Roman'],
    'sans-serif': ['Arial', 'Helvetica'],
    // monospace, display, and handwriting categories use defaults
  }
}
```

Overrides stay partial: naming `serif` and `sans-serif` leaves monospace, display and handwriting on their defaults. Those defaults are listed, and the sans serif chain is `BlinkMacSystemFont`, `Segoe UI`, `Helvetica Neue`, `Arial`, `Noto Sans`, while serif is `Times New Roman`, `Georgia`, `Noto Serif`. The monospace entry begins `Courier New`, `Roboto M` and then stops mid word in the published text, so the rest of that chain has to be read out of the source rather than out of any overview.

## Recent tags ship the sibling package, not this one

Watching the repository's history gives a distorted picture of upkeep. The three most recent release entries name `fontless` versions 1.1.0, 1.2.0 and 1.2.1 and never mention a `fontaine` version, so a reader tracking tags sees the sibling package move while this one sits still. The root `package.json` confirms the pair, since the build script `pnpm --filter fontaine --filter fontless build` covers exactly those two. This is a pnpm workspace, with `pnpm-workspace.yaml` and `pnpm-lock.yaml` at the top level and per package work under `packages/`.

Contributing costs more than installing. `devEngines` asks for node `^24.0.0` and pnpm `12.3.4`, each with a download fallback instead of a hard failure, `postinstall` installs `simple-git-hooks` and then builds, and a pre-commit hook runs `pnpm lint-staged`. Verification goes past unit tests: `test:types`, `test:knip` for unused exports, and `test:versions` running `installed-check` across the workspace with the vite example and the fontless docs excluded. Lint tooling is pinned hard, `eslint` 10.12.0, `typescript` 6.0.3, `knip` 6.39.0, alongside `renovate.json` for updates and `CODEOWNERS` for review routing. `fontaine` is published under the MIT licence, the same value the root manifest carries, which is the permissive grant most teams can build on without extra terms.

## Conclusion

Adopt fontaine when your pages swap a self hosted web font for a system font and you can watch the jump in a layout shift trace. Leave it alone when your @font-face rules arrive through a Sass partial or a CSS variable, because the bundler plugin never sees those and the repair is a hand written suffix in your own stylesheet. Before shipping, build once and read the generated CSS: every fallback family the plugin appends needs a matching @font-face rule behind it, and every font name must reach the plugin as a literal rather than through a variable.

## FAQ

### Does fontaine work with Sass or Less stylesheets?

Not through the bundler plugin. It runs before the preprocessor, so @font-face rules inside a Sass partial, a mixin or an @import are inlined before fontaine reads them, and the same applies to Less and Stylus. Use the PostCSS entry point at fontaine/postcss instead, and never both at once.

### How do I make fontaine work when my font is loaded through a CSS variable?

Append a family name made of the font name plus the word fallback to the variable yourself, for example 'Poppins', 'Poppins fallback' inside --ifm-font-family-base. The generated @font-face rule already exists under that name, and naming it in the variable is what makes the browser pick up the crafted metrics.

### Which fallback fonts does fontaine choose for me automatically?

Only when fallbacks is passed as an empty object, which switches selection to font categories. The documented defaults are BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial and Noto Sans for sans serif, Times New Roman, Georgia and Noto Serif for serif, and a monospace chain that the published text cuts off after Courier New and Roboto M. Individual categories can be replaced through categoryFallbacks while the rest keep their defaults.

### Does fontaine ship any JavaScript that runs in the browser?

No. Everything runs at build time through the Vite, webpack or PostCSS plugin, and the output is CSS with no runtime overhead. The metrics overrides and the extra @font-face rules are the only artefacts that reach the page.

### What has to be configured in a webpack setup that uses Sass?

Two things. Source maps must be enabled on sass-loader, because Sass does not rebase the URLs it inlines and the source map is how the plugin resolves which stylesheet a src URL was written relative to. On css-loader, importLoaders has to be set to 2 for postcss-loader plus sass-loader, so imported CSS containing @font-face rules passes through PostCSS.

### What does the fontaine monorepo expect from contributors?

The root package.json declares devEngines requiring node ^24.0.0 and pnpm 12.3.4, both set to download rather than fail. postinstall installs simple-git-hooks and runs a build, a pre-commit hook runs pnpm lint-staged, and test:versions runs installed-check across the workspace.

## Sources

- [Issues](https://github.com/unjs/fontaine/issues)
- [License: MIT](https://github.com/unjs/fontaine/blob/main/LICENSE)
- [README](https://github.com/unjs/fontaine/blob/main/README.md)
- [Releases](https://github.com/unjs/fontaine/releases)
- [unjs/fontaine on GitHub](https://github.com/unjs/fontaine)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/unjs-fontaine
