Left Arrow.Back

Setting up typography in Next.js using vanilla-extract & Capsize

A word annotated with typography metrics.

Setting up CSS font styles in a Next.js app using vanilla-extract & Capsize.

Last Tended
Planted
StatusSeed
The letter 'N' in the middle of a circle.

1. Start a Next.js Project

In the terminal, run:

> npx [email protected] --typescript_
Source
2 people working on a frame of a sci-fi fighter jet in a docking bay.

2. Remove Unrequired Boilerplate

Delete

Delete the following directories & files highlighted in red as they won't be required:

my-next-app  ├── pages   
├── api
├── _app.ts
└── index.tsx
├── public
├── favicon.ico
└── vercel.svg
├── styles ...

Modify

Replace the contents of the following files:

/pages/_app.tsx

import type { AppProps } from "next/app";
export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}

/pages/index.tsx

import type { NextPage } from "next";
const Home: NextPage = () => {
return <div>homePlaceholder</div>;
};
export default Home;
The words 'Google Fonts'.

3. Add Font Files

For this example, we will only be using 1 font family, Inter. Download the family & move the variable font file Inter-VariableFont_slnt,wght.ttf into the directory /public/fonts/.

Cupcake.

4. Install vanilla-extract

For this example, we'll be using vanilla-extract to generate our CSS. In the terminal, run:

> npm install @vanilla-extract/css @vanilla-extract/next-plugin_

Replace the /next.config.js file with the following:

/next.config.js

const { createVanillaExtractPlugin } = require("@vanilla-extract/next-plugin");
const withVanillaExtract = createVanillaExtractPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = withVanillaExtract(nextConfig);
Source
A canoe with oar.

5. Install Capsize

Capsize is a library that trims unwanted space above & below typography that is applied by the browser. In the terminal, run:

> npm install @capsizecss/core @capsizecss/vanilla-extract @capsizecss/metrics_
Source
'Times New Roman' written repeatedly in different font styles.

6. Create Typography Styles

Create a file /styles/typography.css.ts. This file will hold all typographic information & define all font styles. Add the following:

/styles/typography.css.ts

import { FontMetrics } from "@capsizecss/core";
import interFontMetrics from "@capsizecss/metrics/inter";
import { createTextStyle } from "@capsizecss/vanilla-extract";
import { style } from "@vanilla-extract/css";
interface Meta {
fallback: string;
files: {
variable?: string;
normal?: string;
bold?: string;
};
format: string;
metrics: FontMetrics;
name: string;
weights: {
variable?: string;
normal?: number;
bold?: number;
};
}
type FontFamilyId = "INTER";
type Fonts = Record<FontFamilyId, Meta>;
const FONT_DIR = `/fonts`;
export const fonts: Fonts = {
INTER: {
fallback: `sans-serif`,
files: {
variable: `${FONT_DIR}/Inter-VariableFont_slnt,wght.ttf`,
},
format: `truetype-variations`,
metrics: interFontMetrics,
name: `Inter`,
weights: {
variable: `100 900`,
normal: 400,
bold: 700,
},
},
};
// https://type-scale.com
// Major Third
// base: 16
const typeScale = {
s: 12.8,
m: 16,
l: 20,
xl: 25,
};
interface Props {
id: FontFamilyId;
leading: number;
size: number;
}
function calcFontCss({ id, leading, size }: Props) {
return style([
createTextStyle({
fontMetrics: fonts[id].metrics,
fontSize: size,
leading,
}),
{
fontFamily: `"${fonts[id].name}", ${fonts[id].fallback}`,
},
]);
}
type StyleId = "INTER_SMALL" | "INTER_MED" | "INTER_LARGE" | "INTER_XLARGE";
export const fontStyles: Record<StyleId, string> = {
INTER_SMALL: calcFontCss({
id: "INTER",
leading: 16.5,
size: typeScale.s,
}),
INTER_MED: calcFontCss({
id: "INTER",
leading: 21,
size: typeScale.m,
}),
INTER_LARGE: calcFontCss({
id: "INTER",
leading: 25,
size: typeScale.l,
}),
INTER_XLARGE: calcFontCss({
id: "INTER",
leading: 32,
size: typeScale.xl,
}),
};
export const fontFiles = Object.values(fonts).flatMap((font) => Object.values(font.files));

We are only using 1 font-family but this file is structured to allow more if required. It also allows for non-variable fonts. The font sizes were calculated using type-scale. Notes on why unions are used instead of enums can be found here.

The word loading

7. Load Fonts

Create a file /styles/app.css.ts. This will act as the CSS reset & where font families will be loaded. Add the following:

/styles/app.css.ts

import { globalFontFace, globalStyle } from "@vanilla-extract/css";
import { fonts } from "./typography.css";
globalFontFace(`"${fonts.INTER.name}"`, {
fontDisplay: `optional`,
fontStyle: `normal`,
fontWeight: fonts.INTER.weights.variable,
src: `url("${fonts.INTER.files.variable}") format("${fonts.INTER.format}")`,
});
globalStyle("*, *::before, *::after", {
boxSizing: "border-box",
margin: 0,
padding: 0,
});
globalStyle("p, strong, h1, h2, h3, h4, h5, h6", {
overflowWrap: `break-word`,
fontWeight: 400,
});

fontDisplay: 'optional' is being used to prevent layout shifting & a flash of invisible text on page load. This also requires preloading font files. Create a file /pages/_document.tsx & add the following:

/pages/_document.tsx

import Document, { Head, Html, Main, NextScript } from "next/document";
import React from "react";
import { fontFiles } from "../styles/typography.css";
export default class MyDocument extends Document {
static async getInitialProps(ctx: any) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html lang="en">
<Head>
{fontFiles.map((file) => (
<link as="font" crossOrigin="anonymous" href={file} key={file} rel="preload" />
))}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

Using fontDisplay: 'optional' sets an extremely small block period & no swap period for font loading. In my experience, even on a fast connection (25 Mbps), the font won't always load in time & the page will display the fallback. If you require to always display the correct font & can accept a layout shift, fontDisplay: 'swap' might be a better setting to use. More information on font-display values can be found here.

To set the CSS reset styles in all pages within the app, import the app.css.ts into /pages/_app.ts:

/pages/_app.tsx

import type { AppProps } from "next/app";
import "../styles/app.css";
export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
A baby test dummy being fired upon by machine guns while 2 people watch.

8. Test

Lets visually test the font styles to ensure everything is working correctly. Normally I would do this using Storybook but that is out of the scope of this note. Create a file /styles/test.css.ts & add the following:

/styles/test.css.ts

import { style } from "@vanilla-extract/css";
import { fontStyles, fontWeights } from "./typography.css";
export const layout = style({
padding: `16px`,
display: `flex`,
flexDirection: `column`,
alignItems: `flex-start`,
gap: `16px`,
});
export const label = fontStyles.interS;
export const p = fontStyles.interM;
export const h2 = fontStyles.interL;
export const h1 = fontStyles.interXL;
export const boldText = style([
fontStyles.interM,
{
fontVariationSettings: `"wght" ${fontWeights.inter.bold}`,
color: `red`
},
]);

This file creates a CSS class using each of our defined font sizes. The last class, boldText, is showing how a font style can be extended & how to use 1 of the font weights we defined earlier. To use these styles, modify the pages/index.tsx page as follows:

/pages/index.tsx

import type { NextPage } from "next";
import * as styles from "../styles/test.css";
const Home: NextPage = () => {
return (
<div className={styles.layout}>
<h1 className={styles.h1}>my h1</h1>
<h2 className={styles.h2}>my h2</h2>
<p className={styles.p}>my paragraph</p>
<label className={styles.label}>my label</label>
<p className={styles.boldText}>my bold text</p>
</div>
);
};
export default Home;
Placeholder text running down the screen with outlines around them.

9. Result

The end result is typography rendering at the defined size & weight with unwanted space above & below trimmed off. The outlines around the text where produced from a browser extension: Pesticide for Chrome.

Sandbox

I wasn't able to get a sandbox working with Next.js & vanilla-extract. As a workaround, I've created a GitHub repo.