Swap font pairs in one place.
Typography in Loop is centralized so you can swap an entire font pairing in one place, with no hunting through components.
Loop self-hosts every typeface with next/font/local. The .woff2 files are committed to the repo under src/app/fonts/, and src/lib/fonts.ts turns them into CSS variables — one per family, e.g. --font-bricolage.
Because the files ship with the source, next build never contacts Google Fonts. The build works offline, behind a corporate proxy, and on a CI runner with no outbound network, and it cannot break because an upstream font URL moved.
Components never name a typeface. They read three tokens, which src/app/globals.css points at the active pair:
--font-display — headings and the wordmark--font-body — body copy and UI--font-mono — code, labels, and metadataimport localFont from "next/font/local";
const bricolage = localFont({
src: [
{ path: "../app/fonts/bricolage-grotesque-variable-normal-latin.woff2", weight: "400", style: "normal" },
{ path: "../app/fonts/bricolage-grotesque-variable-normal-latin.woff2", weight: "500", style: "normal" },
{ path: "../app/fonts/bricolage-grotesque-variable-normal-latin.woff2", weight: "600", style: "normal" },
],
declarations: [
{ prop: "font-family", value: "bricolage" },
{ prop: "unicode-range", value: "U+0000-00FF, U+0131, U+0152-0153, /* … */" },
],
display: "swap",
variable: "--font-bricolage",
});
Each family is split into unicode subsets — latin, latin-ext, and depending on the family vietnamese, cyrillic, cyrillic-ext, greek, greek-ext. All of them ship, so accented, Vietnamese, Cyrillic and Greek text renders in the real typeface instead of falling back to a system font.
next/font/local accepts a single unicode-range per call, so a family is declared once per subset:
latin call owns the CSS variable, the <link rel="preload"> and the automatic size-adjust fallback;adjustFontFallback: false and preload: false and only contributes its coverage.The font-family declaration is what stitches those calls back into one family. It must match the name of the family's first const — that is the name the bundler writes into the CSS variable.
To change the whole look, edit src/lib/font-pairs.ts — it maps each pair to the var(--font-*) names — then set DEFAULT_FONT_PAIR. The TweakPanel picks new pairs up automatically.
Every heading and paragraph updates because components reference the token, never the font name directly.
Get the .woff2 files. For a Google font, open https://fonts.googleapis.com/css2?family=Your+Family:wght@400;500;600&display=swap in Chrome and save every file the returned CSS links — there is one per subset. For a licensed font, use what your foundry ships.
If the family carries more than one axis, name them all in the URL. The line above asks for wght only, so a family that also has optical size (Fraunces, Newsreader, Roboto Flex…) comes back without it: …?family=Fraunces:opsz,wght@9..144,400..700&display=swap is the correct shape — axes in alphabetical order, ranges in the same order after the @. Fetch it wrong and the font still loads and renders; it simply ignores font-optical-sizing, and nothing in the build or the tests flags it.
Drop them into src/app/fonts/ using the same naming.
Copy an existing family block in src/lib/fonts.ts, repoint the paths, and keep the unicode-range that came with each subset.
Give every call of the family the same font-family declaration, matching the first const's name.
Add the const to the families array at the bottom of the file so its variable lands on <html>.
Reference the new variable from a pair in font-pairs.ts.
Removing a family is the reverse: delete its calls, its families entry, its .woff2 files, and any pair that used it.
The monospace token shows up in code blocks, eyebrow labels, and timestamps. If you change it, glance at the docs pages and the changelog to confirm the new face has the weights those components expect.