Left Arrow.Back

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

\'Times New Roman\' written repeatedly in different font styles.

An approach to setting up CSS font styles in a Next.js app using vanilla-extract & Capsize.

Last Tended-
Planted
StatusSeed
TagsCSS, Next.js, vanilla-extract
The letter 'N' in the middle of a circle.

Start a Next.js Project

In the terminal, run:

terminal

npx [email protected] --typescript
Source

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'.

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.

Install vanilla-extract

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

terminal

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.

Install Capsize

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

terminal

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

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;
};
}
enum Id {
inter = "inter",
}
type Fonts = Record<Id, Meta>;
const FONT_DIR = `/fonts`;
export const fonts: Fonts = {
[Id.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
enum TypeScale {
s = 12.8,
m = 16,
l = 20,
xl = 25,
}
interface Props {
id: Id;
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}`,
},
]);
}
enum StyleId {
interS = "interS",
interM = "interM",
interL = "interL",
interXL = "interXL",
}
export const fontStyles: Record<StyleId, string> = {
[StyleId.interS]: calcFontCss({
id: Id.inter,
leading: 16.5,
size: TypeScale.s,
}),
[StyleId.interM]: calcFontCss({
id: Id.inter,
leading: 21,
size: TypeScale.m,
}),
[StyleId.interL]: calcFontCss({
id: Id.inter,
leading: 25,
size: TypeScale.l,
}),
[StyleId.interXL]: calcFontCss({
id: 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.

A large rectangle fill with smaller rectangles. The last 1 blinking.

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>
);
}
}

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} />;
}

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 { style } from "@vanilla-extract/css";
import { fontStyles, fonts } 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" ${fonts.inter.weights.bold}`,
color: `red`,
},
]);

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

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