# Fonts

> Self-hosted fonts from npm, and what next/font was doing for you.

Install the font, import its CSS, name it in a variable. Vite does the rest.

```sh
bun add @fontsource-variable/fraunces @fontsource-variable/geist
```

```css title="src/app/styles.css"
@import '@fontsource-variable/fraunces/full.css';
@import '@fontsource-variable/fraunces/full-italic.css';
@import '@fontsource-variable/geist';

:root {
  --font-display: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
  --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif,
    'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
```

Put your font in *front* of a full stack rather than in place of one. Two things
the rest of the stack does that a bare `sans-serif` does not:

- **Emoji.** Geist has no emoji glyphs, so an emoji falls through to whatever is
  named after it. With nothing there, some Linux and older Android setups draw a
  monochrome glyph or a box. The four emoji fonts at the end are consulted only
  for characters the main font cannot draw, so they cost nothing.
- **A closer fallback.** During the `swap` window the browser shows the next
  font. `-apple-system` and `Segoe UI` are nearer Geist in width and x-height
  than Helvetica or Arial, so the moment of swap is a smaller jolt.

Those are Tailwind's own defaults, and shadcn's generated `--font-sans:
'Geist Variable', sans-serif` drops both. Keep the stack.

[Fontsource](https://fontsource.org) packages every Google font — and many
others — as woff2 files with the `@font-face` rules already written. Vite
hashes the files and serves them beside your other assets, so **nothing is
fetched from Google at runtime**, and nothing is downloaded at build time
either. The files are in `node_modules`.

## Porting from `next/font`

```ts
// before
const fraunces = Fraunces({
  variable: '--font-display',
  subsets: ['latin'],
  style: ['normal', 'italic'],
  axes: ['SOFT', 'opsz'],
  display: 'swap',
})
```

Every option there is something Fontsource already did:

| `next/font` | Fontsource |
| --- | --- |
| `variable: '--font-display'` | the `:root` rule above — you write the variable, once |
| `subsets: ['latin']` | every subset ships, each with a `unicode-range`; the browser fetches only the ones the page uses |
| `style: ['normal', 'italic']` | `full.css` and `full-italic.css` |
| `axes: ['SOFT', 'opsz']` | `full.css` — every axis of the variable font; `standard.css` is weight alone |
| `display: 'swap'` | already in every rule |
| `className={fraunces.variable}` | nothing — the variable is on `:root` |

So the layout loses its font imports and the `className` gains nothing:

```tsx title="src/app/layout.tsx"
import './styles.css'

export default function RootLayout({ children }) {
  return (
    <html lang="en" className="h-full antialiased dark font-sans">
      …
    </html>
  )
}
```

The subsetting is worth a second look, because it is *better* than what you
had. `next/font` asks you to name subsets at build time and ships those.
Fontsource ships all of them, each behind a `unicode-range`, and the browser
downloads only the ones a page's text actually needs — a Latin-only page
fetches one file whatever else is installed.

## Preloading

The one thing `next/font` did that needs a line here. It added a `<link
rel="preload">` for each font so the browser finds it before the stylesheet
does. Without one, the font is discovered when the CSS is parsed, and the text
shows in the fallback face for a moment longer.

Import the file for its url, and React hoists the link:

```tsx title="src/app/layout.tsx"
import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
      </head>
      …
```

`?url` is Vite's — it hands back the hashed path the build will serve. Preload
the one file the first paint needs, usually the Latin regular; preloading all
of them defeats the subsetting.

## A font never blocks the page

Text paints before the web font arrives, always. That is not a tuning
choice; it is the rule everything below serves, and a font can break it in
three ways, each of which the setup above has already closed:

- **A stylesheet from another origin.** `<link href="https://fonts.googleapis.com/…">`
  is render-blocking CSS from a host the browser has not connected to: a DNS
  lookup, a TLS handshake and a round trip before the first paint, on every
  cold load. Self-hosting through Fontsource keeps the `@font-face` rules in
  your own stylesheet, which is inlined into the document.
- **`font-display: block`, or none at all.** Without a `font-display`, the
  browser is free to hide text for up to three seconds while it waits — the
  invisible-text flash. Every rule the setup writes says `swap` (paint the
  fallback now, swap when the font lands) or `optional` (paint once, in
  whichever is ready). Never `block`, and never leave it unset in a rule you
  own.
- **Preloading everything.** A `<link rel="preload">` per file puts every
  weight and subset ahead of the page in the network queue. Preload the one
  file the first paint needs; the rest arrive with the stylesheet.

A font that is slow — a cold cache, a throttled connection — then costs a
swap or a fallback, never a blank page. The section below is how to make
that swap invisible.

## Getting to 100 on a phone

Fontsource's stylesheet is the right default and the wrong last mile. It
declares every subset of every weight with `font-display: swap`, which is
fine on a laptop and what a throttled phone audit will find: the largest
text on the page paints in the fallback, then repaints when the web font
lands — Lighthouse reads that as a late LCP, and a 99. Three moves took
[rsc-kit.dev](https://rsc-kit.dev) from 99 to a steady 100, and they are
what any page can do once the design is settled:

**Own the `@font-face` rules, latin only.** Import the files you use with
`?url` and declare them yourself, with the latin `unicode-range`. The
declarations shrink from every script to the one you set, and the inlined
stylesheet with them.

**`font-display: optional` for the body faces.** The text then paints once —
in the fallback on a cold load over a slow network, in the web font when it
is cached or quick — and never re-lays out for a font. That is the honest
trade for a page whose first paint is the product: a first-time visitor on
slow 4G reads the fallback. The headline face stays `swap`, because it is
the brand and it is preloaded, so it arrives with the document.

**One weight per family, variable only where you use the axes.** A variable
font carries every weight; if the page uses one, a static file is a third
the size. Declare it to cover the range you ask for (`font-weight: 400 500`)
so nothing requests a file that is not there.

```tsx title="src/app/layout.tsx"
import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-wght-normal.woff2?url'
import plexSans from '@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff2?url'

const LATIN = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD'

const FACES = `
@font-face { font-family: 'Fraunces Variable'; font-weight: 100 900; font-display: swap; src: url(${fraunces}) format('woff2-variations'); unicode-range: ${LATIN}; }
@font-face { font-family: 'IBM Plex Sans'; font-weight: 400 500; font-display: optional; src: url(${plexSans}) format('woff2'); unicode-range: ${LATIN}; }
`

// in <head>: the preloads, then the faces
<link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href={plexSans} as="font" type="font/woff2" crossOrigin="anonymous" />
<style>{FACES}</style>
```

What it measured, on a throttled Moto G against the same page: Speed Index
1.7 s to 0.9 s, three runs of 100 where there had been 99s, and 74 kB of
fonts where there had been 114. Do it last: it is tuning, and the design
has to be settled before the weights are.

## A font that is not on npm

A file you own goes in `public/` and gets its own `@font-face` in your CSS, the
same as anywhere. Or in `src/` and imported with `?url` as above, which hashes
it.
