How to Configure and Package Fonts with Tamagui and Next.js
Documenting my journey for both my future self and others who might run into the same problem.
Documenting my journey for both my future self and others who might run into the same problem.
Problem
I was trying to add a font using Tamagui's font generation tool, but, the correct font wasn't being rendered to the (this) website. It took a good bit of trail and error to figure out what was going on, and how to fix it.
Importing both the correct font files for web, mobile and general configuration is tetious, and error prone and just generally something that most developers don't want to spend time on.
After digging around the generated package a bit, there were a couple of issues that stuck out:
Only .ttf files were generated and packaged. For web projects .woff2 files are generally the best option, for many reasons. There was no .css files generated or rendered with the page. There are a couple of hardcoded css variables that are used in the generated package. Only the font-family was added to the component without changing the actual font being rendered to the screen.
Using the correct file type
For web using the .woff2 file type is the most performant, and generally the best option. For mobile, using the .otf file type.
Looking into how Tamagui configures fonts
The @tamagui/inter package that is shipped by default was my starting place after trying to configure fonts generated with the tamagui cli tool.
Digging into @tamagui/inter
There were a couple of things that I noticed that were different from the generated package and the @tamagui/inter package.
Fonts are base64 via subset.ts encoding for the fonts, which is generally not recommended. It exported a css file that was imported into the project.
Looking at the Tamagui.dev website source code
cmd + shift + u and searching on .woff2, fonts are being loaded from public/ and served like any regular font.
Base64 encoding
I did a bit of searching and didn't find a compelling reason to use base64 encoding for fonts. It's generally not recommended, and can cause performance issues.
Solution
Add next/font to your _app.tsx file. Add a className and font variable in your root. Pull out a generic createFont function from @tamagui/inter and use it in your project.
import "@tamagui/core/reset.css";
import { Source_Serif_4, EB_Garamond } from "next/font/google";
import "raf/polyfill";
import React, { ReactElement, ReactNode } from "react";
import Head from "next/head";
import { GoogleAnalytics } from "@next/third-parties/google";
import { NextThemeProvider, useRootTheme } from "@tamagui/next-theme";
import { Provider } from "app/provider";
import { AppProps } from "next/app";
import { NextPage } from "next";
import type { SolitoAppProps } from "solito";
import { isWeb } from "tamagui";
if (process.env.NODE_ENV === "production") {
require("../public/tamagui.css");
}
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
export type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
const bodyFont = Source_Serif_4({
subsets: ["latin"],
display: "swap",
variable: "--my-body-font",
});
const displayFont = EB_Garamond({
subsets: ["latin"],
display: "swap",
variable: "--my-display-font",
});
function MyApp({ Component, pageProps }: SolitoAppProps) {
const getLayout = Component.getLayout || ((page) => page);
console.log(isWeb);
return (
<>
<Head>
<title>bensch.ac</title>
<meta name="bensch.ac" content="benjamin's blog" />
<link rel="icon" href="/favicon.ico" />
<script
dangerouslySetInnerHTML={{
// avoid flash of animated things on enter:
__html: `document.documentElement.classList.add('t_unmounted')`,
}}
/>
</Head>
<ThemeProvider>
{getLayout(<Component {...pageProps} />)}
{process.env.NODE_ENV !== "production" && (
<GoogleAnalytics gaId="G-M7M1C0YG29" />
)}
</ThemeProvider>
</>
);
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useRootTheme();
return (
<NextThemeProvider
// change default theme (system) here:
// defaultTheme="light"
onChangeTheme={(next) => {
setTheme(next as any);
}}
>
<Provider disableRootThemeClass disableInjectCSS defaultTheme={theme}>
<div className={`${bodyFont.variable} ${displayFont.variable}`}>
{children}
</div>
</Provider>
</NextThemeProvider>
);
}
export default MyApp;
and in your tamagui configuration
import { createTamagui } from "tamagui";
import { shorthands } from "@tamagui/shorthands";
import { tokens, themes } from "@tamagui/config/v3";
import { createMedia } from "@tamagui/react-native-media-driver";
import {
FillInFont,
GenericFont,
createFont,
getVariableValue,
isWeb,
} from "@tamagui/core";
const LINE_HEIGHT = 1.333;
const createDisplayFont = <A extends GenericFont>(
font: Partial<A> = {},
{
sizeLineHeight = (size) => size,
sizeSize = (size) => size,
}: {
sizeLineHeight?: (fontSize: number) => number;
sizeSize?: (size: number) => number;
} = {}
): FillInFont<A, keyof typeof defaultSizes> => {
// merge to allow individual overrides
const size = Object.fromEntries(
Object.entries({
...defaultSizes,
...font.size,
}).map(([k, v]) => [k, sizeSize(+v)])
);
return createFont({
family: isWeb
? 'var(--my-display-font), -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
: "Playfair Display",
lineHeight: Object.fromEntries(
Object.entries(size).map(([k, v]) => [
k,
sizeLineHeight(getVariableValue(v) * LINE_HEIGHT),
])
),
weight: {
4: "300",
},
letterSpacing: {
4: 0,
},
...(font as any),
size,
});
};
const createBodyFont = <A extends GenericFont>(
font: Partial<A> = {},
{
sizeLineHeight = (size) => size,
sizeSize = (size) => size,
}: {
sizeLineHeight?: (fontSize: number) => number;
sizeSize?: (size: number) => number;
} = {}
): FillInFont<A, keyof typeof defaultSizes> => {
// merge to allow individual overrides
const size = Object.fromEntries(
Object.entries({
...defaultSizes,
...font.size,
}).map(([k, v]) => [k, sizeSize(+v)])
);
return createFont({
family: isWeb
? 'var(--my-body-font), -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
: "Playfair Display",
lineHeight: Object.fromEntries(
Object.entries(size).map(([k, v]) => [
k,
sizeLineHeight(getVariableValue(v) * LINE_HEIGHT),
])
),
weight: {
4: "300",
},
letterSpacing: {
4: 0,
},
...(font as any),
size,
});
};
const defaultSizes = {
1: 11,
2: 12,
3: 13,
4: 14,
true: 14,
5: 16,
6: 18,
7: 20,
8: 23,
9: 30,
10: 46,
11: 55,
12: 62,
13: 72,
14: 92,
15: 114,
16: 134,
} as const;
import { animations } from "@my/ui/src/animations";
const headingFont = createDisplayFont({
size: {
6: 15,
},
transform: {
6: "uppercase",
7: "none",
},
weight: {
6: "400",
7: "700",
},
color: {
6: "$colorFocus",
7: "$color",
},
letterSpacing: {
5: 2,
6: 1,
7: 0,
8: -1,
9: -2,
10: -3,
12: -4,
14: -5,
15: -6,
},
face: {
face: {
// .. other font weights
700: { normal: "YOUR_FONT" },
},
},
});
const bodyFont = createBodyFont(
{
face: {
// .. other font weights
700: { normal: "YOUR_FONT" },
},
},
{
sizeSize: (size) => Math.round(size * 1.1),
sizeLineHeight: (size) => Math.round(size * 1.1 + (size > 20 ? 10 : 10)),
}
);
export const config = createTamagui({
defaultFont: "body",
animations,
shouldAddPrefersColorThemes: true,
themeClassNameOnRoot: true,
// highly recommended to turn this on if you are using shorthands
// to avoid having multiple valid style keys that do the same thing
// we leave it off by default because it can be confusing as you onboard.
onlyAllowShorthands: false,
shorthands,
fonts: {
body: bodyFont,
heading: headingFont,
},
settings: {
allowedStyleValues: "somewhat-strict",
},
themes,
tokens,
media: createMedia({
xs: { maxWidth: 660 },
sm: { maxWidth: 800 },
md: { maxWidth: 1020 },
lg: { maxWidth: 1280 },
xl: { maxWidth: 1420 },
xxl: { maxWidth: 1600 },
gtXs: { minWidth: 660 + 1 },
gtSm: { minWidth: 800 + 1 },
gtMd: { minWidth: 1020 + 1 },
gtLg: { minWidth: 1280 + 1 },
short: { maxHeight: 820 },
tall: { minHeight: 820 },
hoverNone: { hover: "none" },
pointerCoarse: { pointer: "coarse" },
}),
});
// for the compiler to find it
export default config;
your fonts should be rendering as expected on your next.js site.