Left ArrowBack

notes / CSS / typography setup

typography setup

A word annotated with typography metrics.

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

Last Tended

Status: seed

Letters in the browser render white space above & below them. From a design perspective however, this vertical space should be reduced to the height of a capital letter. Capsize fixes this. Trimming the empty space. This note details how to set up fonts & Capsize in a Next.js project. It assumes only variable fonts are being used.

Text with empty space above & 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 [email protected] --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 & 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

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 & 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 & 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}`,
    },
  ]);
}

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

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 & 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 & a flash of invisible text on page load. This 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 { 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 & 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.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 &
  • how to use 1 of the font weights we defined earlier.

Render some text & 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 & 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.

A space ship's docking bay with various equipment.

Resources

People to Follow
Mark DalgleishFormer team lead that created vanilla-extract
Tools
Leading TrimTrim text empty space in Figma

Where to Next?

A sci-fi robot taxi driver with no lower body