Engineering field note16 min read

How to Build a Typography System in Tailwind CSS v4

A step-by-step guide to building a centralized typography system in Tailwind CSS v4: shared recipes, a typed Text component, lint enforcement, and tests using the v4 compile API.

Adeel Imran

Written by

Adeel Imran

Cover Image for How to Build a Typography System in Tailwind CSS v4
Photo by Ries Bosch on Unsplash

You tweak a heading on one page until it looks right, then open a different page and find almost the same heading wearing a slightly different outfit: one weight heavier, tracking a notch tighter, a breakpoint nobody remembers adding.

So you copy the classes over and move on. A week later, a third variation shows up somewhere else, and now you're the one who wrote it.

You've probably already tried the usual fixes: a Slack message asking everyone to "stay consistent," a design-system doc that got read once during onboarding, a new shared component that felt like a solution for about a month. None of those fix the actual problem, because none of them make the wrong thing harder to do than the right thing.

I ran into all of this on my own site, which was embarrassing given that I already had a reusable Text component.

The component existed. The discipline around it didn't.

Every page was still free to invent its own font size, and mine took full advantage of that freedom. Bolting on another variant wasn't going to fix the underlying habit; I needed the wrong thing to become genuinely inconvenient to write.

So that's what I built: every typography decision pulled out of components and into one place, wired through Tailwind, and then locked down by a linter so the old habit can't quietly come back. When I migrated my own site's codebase to this system, raw typography references dropped from 440 down to 20 across the same application and component source files, a 95.45% reduction. The number is a nice headline, but the part that actually matters day to day is quieter: headings, body copy, UI controls, and rendered blog posts all pull from the same small set of recipes now, instead of each page guessing.

Here's the whole build, in the order I'd actually do it starting from a fresh Tailwind v4 project: the recipe file, the Tailwind wiring, the Text component, the class-merging detail, and how to test the system so it stays locked down.


1. Find the typography decisions hiding in your components

Before you write a single recipe, you need to know what you're replacing. Run this search across your codebase:

rg -n 'text-(xs|sm|base|lg|xl|[0-9]+xl)|font-|leading-|tracking-|fontSize|lineHeight' app components

This finds every place a component sets its own font size, weight, line height, or tracking, whether through a Tailwind utility class or an inline style. Open the results and read each one instead of trusting the count.

Here's a typical match:

<h2 className="mb-6 text-2xl font-semibold leading-tight tracking-tight md:text-3xl">
  Selected writing
</h2>

The problem only shows up once you run the same search against four or five other components and find that each one built its own version of "a section heading," and none of them agree on weight, tracking, or the breakpoint where the size changes.

Once you've read through the matches, group them by what they're for:

PurposeStarting recipe
Section headingh2
Main copybody
Introductory copylead
Buttons and navigationui
Long articlesarticle
Inline strong emphasisstrong

Only list roles you can point to on a real screen in your app right now. Labels, captions, and the remaining heading levels can join later, once the system exists and migrating them is a five-minute job instead of a design decision.

With a short list of roles in hand, you're ready to write down what each one actually looks like.


2. Build a typography system around complete recipes

With your roles listed, turn each one into a single, complete object: every property that defines how that role looks, in one place, with nothing left for a component to override.

Add this file at lib/design/typography.mjs:

const sans = "var(--font-sans, ui-sans-serif), system-ui, sans-serif";

function copy(fontSize, lineHeight = "1.7", fontWeight = "400") {
  return {
    fontFamily: sans,
    fontSize,
    lineHeight,
    fontWeight,
    fontStyle: "normal",
    letterSpacing: "0",
    textTransform: "none",
  };
}

export const typography = {
  h2: {
    ...copy("clamp(1.875rem, 1.5rem + 1.5vw, 2.5rem)", "1.2", "600"),
    letterSpacing: "-0.025em",
    textWrap: "balance",
  },
  body: copy("clamp(1rem, 0.95rem + 0.25vw, 1.125rem)"),
  lead: copy("clamp(1.125rem, 1rem + 0.5vw, 1.375rem)", "1.6"),
  ui: copy("0.875rem", "1.5", "500"),
  article: copy("clamp(1.0625rem, 0.975rem + 0.35vw, 1.1875rem)", "1.8"),
  strong: { fontWeight: "600" },
};

export const readingMeasure = "65ch";

copy() is a small factory that fills in sensible defaults for line height and weight, so you're not repeating fontFamily, fontStyle, letterSpacing, and textTransform six times. Each entry in typography names one role and calls that factory, adding only what makes the role different.

The rule to hold onto: a recipe owns every value that should always travel together, size, weight, line height, family, and tracking. Spacing around the element (margins, padding) is a layout decision, so it has no business in this file. Color is a theme decision, same deal. Keep those out and a recipe stays reusable everywhere.

The h2 recipe sets textWrap: "balance", which asks the browser to distribute a heading's words evenly across its lines instead of leaving one short, awkward word dangling on the last line.

The font sizes use clamp(), a CSS function that picks a value between a minimum and maximum. Body copy grows smoothly between a phone and a wide desktop viewport without every paragraph needing its own md:text-lg lg:text-xl pile of responsive classes.

strong is the deliberate exception to the "complete recipe" rule: it only sets fontWeight, so it inherits whatever size and line height already surround it. Inline emphasis inside an article shouldn't suddenly drop to UI text size just because it got bold.

Once this file exists, you have a single place to change what "h2" means across your entire app. You still can't use it from a className yet, that's the next step, but the decision itself now lives in exactly one place instead of forty.


3. Wire recipes into Tailwind v4 with @plugin

Tailwind v4 is CSS-first: no tailwind.config.ts, no content array, no @tailwind base/components/utilities directives. Your first instinct might be to hand-write a static @utility block per role:

@utility type-body {
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  line-height: 1.7;
}

That works for one role, but it can't loop over the typography object you just wrote. @utility bodies are static CSS; they can't read a JavaScript file. For a system with more than a couple of recipes, you need a plugin instead.

Create lib/design/tailwind-typography-plugin.mjs:

import plugin from "tailwindcss/plugin";
import { typography } from "./typography.mjs";

export default plugin(({ addComponents }) => {
  addComponents(
    Object.fromEntries(
      Object.entries(typography).map(([name, styles]) => ["." + ["type", name].join("-"), styles])
    )
  );
});

This turns typography.h2 into .type-h2, typography.body into .type-body, and so on down the list, generated fresh from whatever's in the recipe file. It uses addComponents, the same v3-style plugin API Tailwind has supported for years.

Now load it from your main stylesheet, app/globals.css:

@import "tailwindcss";
@plugin "../lib/design/tailwind-typography-plugin.mjs";

@theme {
  --container-reading: 65ch;
}

@plugin is v4's compatibility directive for legacy JavaScript plugins: addComponents, addUtilities, addBase, and matchUtilities all still work through it, and it can sit right next to @theme and @utility in the same file. That combination, static theme tokens plus a dynamic plugin, is what makes a recipe system with dozens of roles practical instead of dozens of hand-written blocks.

The --container-reading variable also does something on its own: v4's --container-* theme namespace auto-generates a matching max-w-* utility, so --container-reading: 65ch gives you max-w-reading for free.

With the plugin registered, the heading from section 1 becomes:

<h2 className="type-h2 mb-6">Selected writing</h2>

The component still decides its own margin (mb-6); the recipe file decides what the heading looks like. Layout stays local, appearance is shared.

Use complete class names when selecting recipes, even conditionally:

const treatment = featured ? "type-lead" : "type-body";
<p className={treatment}>A description of the project.</p>;

Avoid building `type-${role}` at runtime. Tailwind scans your source text for literal class names; it never executes your JavaScript to figure out what a template string resolves to. Its class detection guide spells out the limitation, and it's an easy one to hit the first time a variant variable is sitting right there in scope.


4. Teach your class merger about complete recipes

This step is easy to skip if clsx and tailwind-merge behind a cn() helper already feel like a solved problem.

Here's the failure mode: a component supplies type-body, and whoever calls it supplies type-lead. Keep both classes and you're handing the decision to CSS specificity and source order, exactly the coin flip you were trying to eliminate.

The fix is to register complete recipes as one conflict group. Install tailwind-merge v3 or later; v2's built-in class definitions still assume Tailwind v3 naming and won't recognize v4's renamed utilities like shadow-xs or outline-hidden, which will make it merge things incorrectly on a v4 project:

npm install clsx "tailwind-merge@^3"

Create lib/design/merge-classes.mjs:

import { extendTailwindMerge } from "tailwind-merge";

export const mergeClasses = extendTailwindMerge({
  extend: {
    classGroups: {
      typography: ["type-h2", "type-body", "type-lead", "type-ui", "type-article"],
      "typography-emphasis": ["type-strong"],
    },
  },
});

Wire it into lib/utils.ts:

import { clsx, type ClassValue } from "clsx";
import { mergeClasses } from "./design/merge-classes.mjs";

export function cn(...inputs: ClassValue[]) {
  return mergeClasses(clsx(inputs));
}

Now the last complete recipe applied wins over an earlier one, and type-body type-strong survives intact instead of one clobbering the other, because emphasis lives in its own group. Keep the group names in sync whenever you add a recipe.

One caveat: this merger doesn't stop anyone from tacking text-xl on right next to a recipe class. Nothing about class merging catches that.

Linting is what handles it, in section 7.


5. Build a minimal Text component

Reach for Text when authoring headings and copy; it hands the developer named, typed choices instead of a blank className. A button, a link, or an animated heading can consume the same recipe class directly and skip the wrapper entirely. Either path renders identical CSS.

A starting version of components/ui/text.tsx:

import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";

const recipes = {
  h2: "type-h2",
  body: "type-body",
  lead: "type-lead",
  ui: "type-ui",
  article: "type-article",
} as const;

type Variant = keyof typeof recipes;
type TextElement = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span";

interface TextProps extends HTMLAttributes<HTMLElement> {
  as?: TextElement;
  variant?: Variant;
}

export function Text({ as, variant = "body", className, ...props }: TextProps) {
  const Component = as ?? (variant === "h2" ? "h2" : "p");
  return <Component className={cn(recipes[variant], className)} {...props} />;
}

The variant map is a set of static class names, exactly what lets Tailwind discover them at build time. as controls the HTML element, completely independent of how it looks. Add CVA, ref forwarding, or Radix Slot on top if your app already relies on them; the part worth cutting is independent size and weight props, since those let a named variant quietly turn back into a one-off custom combination.

<Text as="h3" variant="h2">Deployment checklist</Text>
<Text variant="body" className="mt-4 text-muted-foreground">
  Check the release before promoting it.
</Text>
<a href="/releases" className="type-ui underline">View releases</a>

Pick heading levels from the document structure. Pick recipes from the visual role. Those two questions don't always land on the same answer, and that's fine.


6. Cover Markdown and CSS Modules

If you render Markdown into HTML anywhere on your site, there isn't a React Text component inside that output. Fixing JSX alone leaves article typography sitting on its own island.

A Markdown CSS module can lean on the same generated classes:

@reference "../../app/globals.css";

.markdown {
  @apply type-article max-w-reading;
}

.markdown h2 {
  @apply type-h2 mb-5 mt-16;
}

.markdown p {
  @apply my-7;
}

.markdown strong {
  @apply type-strong;
}

That @reference line at the top is not optional. Next.js, and most bundlers, compile each CSS Module file independently, so without it the module can't see the utilities your @plugin and @theme generate elsewhere, and the build fails with "Cannot apply unknown utility class." @reference makes those utilities resolvable for @apply in this file without duplicating their output into it. Any CSS Module that applies a type-* recipe needs this one line first.

The module still owns article spacing, margins and padding are a layout concern, but it no longer invents its own heading sizes or paragraph line heights. The article recipe can stay a little more generous than UI copy, because reading a tutorial and scanning a menu are genuinely different tasks.


7. Enforce it with linting

A recipe file, on its own, does nothing to stop the next component from showing up with text-lg font-semibold pasted straight into it. Good intentions don't survive a deadline.

Add a local ESLint rule for your application source, plus a CSS validator, both reading from the same policy of permitted classes. The editor catches the mistake the moment it's typed; the command line catches anything that slips through before it lands on main.

Here's the behavior worth enforcing:

InputExpected result
type-body text-muted-foreground✅ Allow the recipe and color
md:type-lead✅ Allow a known responsive recipe
type-bdoy❌ Reject the unknown recipe
text-lg, font-medium, leading-7❌ Reject local typography values
Inline fontSize or lineHeight❌ Reject local declarations
A native heading without a recipe❌ Require an explicit treatment

Start with ESLint's custom rule tutorial. Inspect string literals and template fragments for classes, and JSX attributes and object properties for inline typography. Scope the rule to application code only, so documentation and test fixtures can still show invalid input as an example without tripping the lint themselves.

Two details are worth getting right. Parse variants without breaking arbitrary selectors: splitting a class on every colon fails the moment you hit something like [&:nth-child(2)]:text-lg, so only strip variant separators that sit outside brackets or parentheses. Read the allowed recipe names from the definitions themselves, not a manually maintained allowlist, which will drift out of sync with the system it's supposed to be checking within a month.

CSS needs a separate path: parse declarations and @apply rules with PostCSS, run utility names through the same shared policy, and only allow raw values in the central definitions and whatever exceptions you've explicitly documented.


8. Test the system with Tailwind's compile API

Linting catches human mistakes as they happen. Testing catches regressions you didn't foresee when you change the system itself.

Start with the class merger. Save this as scripts/typography.test.mjs:

import assert from "node:assert/strict";
import test from "node:test";
import { mergeClasses } from "../lib/design/merge-classes.mjs";

test("a complete recipe replaces an earlier recipe", () => {
  assert.equal(mergeClasses("type-body px-4", "type-lead"), "px-4 type-lead");
});

test("inline emphasis keeps the surrounding recipe", () => {
  assert.equal(mergeClasses("type-body type-strong"), "type-body type-strong");
});

test("responsive overrides preserve the base recipe", () => {
  assert.equal(mergeClasses("type-body md:type-lead", "md:type-ui"), "type-body md:type-ui");
});

Add a script for it:

{
  "scripts": {
    "test:typography": "node --test scripts/typography.test.mjs"
  }
}

Extend this with CSS validation cases, editor refresh tests, and, most importantly, a check that actually compiles your recipes through Tailwind. My own suite sits at 34 passing tests, more than I expected to write for a project about font sizes.

For the compile check, don't reach for anything from Tailwind v3: tailwindcss/loadConfig.js and postcss([tailwindcss(config)]) are gone in v4. The v4-native way to compile CSS programmatically is @tailwindcss/node's compile():

import { compile } from "@tailwindcss/node";
import { readFile } from "node:fs/promises";
import path from "node:path";

const globalsCss = await readFile("app/globals.css", "utf8");
const { build } = await compile(globalsCss, {
  base: path.resolve("app"),
  onDependency: () => {},
});

const css = build(["type-h2", "type-body", "max-w-reading"]);

Three things worth knowing before you rely on this. base controls how relative @import, @plugin, and @reference paths resolve, and it has to match the directory the CSS file actually lives in; compiling app/globals.css needs base: path.resolve("app"). build(candidates) takes an explicit array of class names to generate, there's no more content: [{ raw: ... }] trick to fake a content scan. And v4 tree-shakes unused @theme variables, so something like --container-reading won't show up in the output at all unless a requested candidate actually uses it, include "max-w-reading" in the array if you want to assert on it.

If you also want to test a CSS Module in isolation, to prove its @reference line actually works, compile it on its own with base set to that file's directory, not your main stylesheet's:

const markdownCss = await readFile("components/blog/post-body.module.css", "utf8");
const { build: buildMarkdown } = await compile(markdownCss, {
  base: path.resolve("components/blog"),
  onDependency: () => {},
});

Wire the checks into a pre-commit hook so a broken recipe can't land on main:

set -e
npx lint-staged
npm run lint:typography:css
npm run test:typography

set -e stops the sequence the moment one command fails. Run the same commands in CI too; local hooks can be skipped, CI is the version everyone actually trusts.


Migrate one page first

Pick a page with a heading, a paragraph, a link, and a control. That's enough variety to surface real problems without rewriting your whole app in one sitting.

  1. Record how the page currently looks and name the roles you see on it.
  2. Add the smallest useful set of recipes and migrate just those elements.
  3. Check narrow and wide layouts, long titles, dark mode, and 200% zoom.
  4. Add the lint cases that would have caught the old drift.
  5. Expand outward: shared primitives first, then the remaining pages, then Markdown.

Don't blindly swap every text-sm for one recipe just because they happen to match today. A menu item, a date, and some supporting copy might all have started at the same size purely by coincidence, while serving completely different purposes.

Centralizing raises the stakes of a mistake, too. Change a line-height value and you've touched paragraphs, cards, and dialog descriptions all at once. Linting can't tell you whether a heading wraps badly, a translated label clips at the edge, or the contrast you chose is actually comfortable to read. Keep an actual browser open and look at it; nothing here replaces that.

Finally, update the docs your team and any coding assistants actually read. Leave old examples with size and weight props sitting around, and you're quietly teaching the next change, human or AI, to recreate the exact problem you just fixed.


A few honest questions

Isn't this just CSS classes with another name? Pretty much, yes. These are CSS classes generated from shared definitions, and I'm not pretending otherwise. Their value isn't novelty, it's naming reusable text roles and enforcing where those definitions are allowed to live.

Should I replace every native element with Text? No. Reach for Text when its typed variants actually help you write content faster or safer. Keep native elements and existing UI primitives when they already have the behavior you need; just point them at the same recipes.

Will this shrink my CSS bundle by 90%? That's not what the measurement shows. The 95.45% figure describes raw typography references removed from consuming source files, not CSS output size. If bundle size is what you actually care about, measure your production CSS separately.

What if a component really needs a different treatment? Ask whether the difference represents a reusable role somewhere else in the app. If it does, add that role centrally and test it properly. If it belongs to fixed-size artwork or third-party content you don't control, document the boundary instead of quietly weakening the rule for everyone else.


Start with one page and three roles. Change the heading recipe once, then check that both your component headings and your rendered Markdown headings update together. That's a fast, honest test of whether the system holds up as your app grows, or whether it's just tidier for a week.

Need a more consistent React or Tailwind codebase? I help SaaS teams consolidate UI patterns and build maintainable design systems. Book a consultation to discuss your project.

Work together

Need a senior React and JavaScript partner to move faster?

Book a session