Left Arrow

Notes

Typography Setup

A word annotated with typography metrics.

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.

Text with empty space above and below compared with text without
The letter 'N' in the middle of a circle.

1. Start a Next.js Project

In the terminal, run:

> npx create-next-app@latest --typescript_
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 and 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 and move the variable font file Inter-VariableFont_slnt,wght.ttf into the directory /public/fonts/.

Cupcake.

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);
A canoe with oar.

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

The word 'loading'

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} />;
}
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 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;
Placeholder text running down the screen with outlines around them.

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?