Setting up typography in Next.js using vanilla-extract and Capsize
Tended:
Status: sprout
Letters in the browser render white space above and below them. From a design perspective, this vertical space should be reduced to the height of a capital letter. Capsize enables this by trimming the empty space. This note details how to set up fonts and Capsize in a Next.js project. It assumes only variable fonts are used.
1. Start a Next.js Project
In the terminal, run:
> npx create-next-app@latest --typescript_

2. Remove Unrequired Boilerplate
Delete
Delete the following directories and files highlighted in red as they won't be required:
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;
3. Add Font Files
For this example, we will only be using 1 font family, Inter.
Download the family and move the variable font file Inter-VariableFont_slnt,wght.ttf
into the directory /public/fonts/
.
4. Install vanilla-extract
We will 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);
5. Install Capsize
Capsize is a library that trims unwanted space above and below typography that is applied by the browser. In the terminal, run:
> npm install @capsizecss/core @capsizecss/vanilla-extract @capsizecss/metrics_
6. Create Typography Styles
Create a file /styles/typography.css.ts
.
This file will hold all typographic information and define all font styles.
/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; file: string; format: string; metrics: FontMetrics; name: string; wghtRange: string; wghts: { [key: string]: number; }; } type FontFamilyId = "INTER"; type Fonts = Record<FontFamilyId, Meta>; const FONT_DIR = `/fonts`; export const fonts: Fonts = { INTER: { fallback: `sans-serif`, file: `${FONT_DIR}/Inter-VariableFont_slnt,wght.ttf`, format: `truetype-variations`, metrics: interFontMetrics, name: `Inter`, wghtRange: `100 900`, wghts: { "400": 400, "700": 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}`, }, ]); } export const fontStyles = { 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, }), };
We are only using 1 font-family but this file is structured to allow more if required.
To get information about a font-family like the value for wghtRange
, use wakamaifondue.
The font sizes were calculated using type-scale.
Notes on why unions are used instead of enums can be found here.
7. Load Fonts
Create a file /styles/app.css.ts
.
This will act as the CSS reset and where font families will be loaded.
/styles/app.css.ts
import { globalFontFace, globalStyle } from "@vanilla-extract/css"; import { fonts } from "./typography.css"; Object.values(fonts).forEach(({ name, wghtRange, file, format }) => { globalFontFace(`"${name}"`, { fontDisplay: `optional`, fontStyle: `normal`, fontWeight: wghtRange, src: `url("${file}") format("${format}")`, }); }); globalStyle("*, *::before, *::after", { boxSizing: "border-box", margin: 0, padding: 0, }); globalStyle("p, strong, h1, h2, h3, h4, h5, h6", { fontVariationSettings: `"wght" ${fonts.INTER.wghts[400]}`, });
fontDisplay: 'optional'
is being used to prevent layout shifting and a flash of invisible text on page load.
This requires preloading font files.
This will schedule the font file to be downloaded and cached with a higher priority.
Ensuring it will be available earlier and less likely to block the page's render.
Create a file /pages/_document.tsx
and add the following:
/pages/_document.tsx
import Document, { Head, Html, Main, NextScript } from "next/document"; import React from "react"; import { fonts } 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> {Object.values(fonts).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 and no swap period for font loading.
In my experience, even on a fast connection (25 Mbps), the font won't always load in time and the page will display the fallback.
If you require to always display the correct font and 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} />; }

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
and 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.INTER_MED, { color: "red", fontVariationSettings: `"wght" ${fonts.INTER.wghts[700]}`, }, ]);
This file creates a CSS class using each of our defined font sizes.
The last class, boldText
, is showing:
- ▪ how more styles can be added to font style class and
- ▪ how to use 1 of the font weights we defined earlier.
Render some text and add these styles in pages/index.tsx
:
/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;

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


