All posts

From 63 font sizes to 14: a design system the build enforces

Nobody decides to ship 63 font sizes. Each screen picks the number that looks right on that screen, and a year later the interface has a dozen sizes nobody can tell apart. This is how Nemi got back to fourteen, why they are whole pixels, why the steps widen above 20px, and why the rule lives in the build rather than in a document.

Design · · updated · 9 min read

When we counted, the Nemi app was using 63 different font sizes. Twelve of them were half pixels: 12.5, 11.5, 13.5. Nobody had chosen any of them. They arrived one screen at a time, each one reasonable on the day it was written, and together they made an interface where two labels that should have matched almost did.

Today the app's font size classes are limited to fourteen steps, and a build with any other class fails. This post is about both halves of that: the scale itself, and the small script that makes it stick. The second half is the one that matters, because we had written down preferences before, and a preference written down in a document lasts about a month.

How does a product end up with 63 font sizes?

Nobody sets out to do it. Somebody builds a sidebar and 13px looks a little large, so the sidebar gets 12.5px. A month later somebody builds a table next to it and matches the table to the sidebar by eye, at 12px. A dialog borrows from both. Every one of those decisions is local and defensible, and none of them is a design decision about the product. Colour had already escaped this, because Nemi's colours were one ramp that each theme redefines, so a component asks for a role and the theme answers. Size, corner radius and shadow had no such system, and they drifted.

The figures recorded when the scale shipped, in docs/DESIGN_SYSTEM.md, were 63 font sizes, 33 corner radii, twenty one off shadows and 1,183 hand written buttons. For this post we wrote a script that recounts it from the code, so the numbers are counted rather than quoted. It reads the same files the guard reads, with the same patterns, at commit e4912003, the last one before the scale existed. It counts in its own way, so its numbers differ from the recorded ones: only the pixel classes written in component files, with every distinct spelling as its own value. We show its numbers from here on, because it is a count we can repeat. By that measure there were 55 distinct sizes, 13 of them half pixels, used 3,263 times. The button count matches the recorded one exactly, which is how we know it is the right commit.

Fourteen steps, and what they replaced

Selected step

14px

Line height
21px, which is 1.5 times the size
Step up from the one below
1.08 times 13px
What it is for
The default: everything unmarked

The highlighted band behind the letters is the line height: the space one line of this size takes up. Watch it get proportionally tighter as the type grows.

The ladder is read from app/globals.css, the sizes before from commit e4912003. Switch views, then drag through the old sizes to see which step each one became.
How we measured

scripts/blog-bench/design-scale/count.ts lists every .tsx file under components/ and app/ at commit e4912003 with git ls-tree, reads each with git show, and collects every text-[Npx] class with the guard's own regular expression. It found 378 files. Sizes written another way (inline styles, CSS files) are not counted, so this is not the same tally as the 63 recorded at the time.

The after state is the working tree on 2026-10-03. The ladder is every --nm-text-* and --nm-lh-* pair parsed from the stylesheet.

Why whole pixels only?

Because a half pixel buys nothing a reader can see. At normal weights, 12.5px text does not render as a size you can tell apart from 12px or 13px on any display we ship to; the difference is swallowed by hinting and antialiasing. What a half pixel actually does is let one label be nudged without touching the label beside it. That is a convenience for whoever is writing the code, paid for by everybody reading the interface, in the form of two things that look as if they were meant to match and do not quite.

Removing them was also the cheapest part of the job. Every half step has two obvious neighbours, and the guard names the nearer one when it refuses a size, so fixing a violation is a one word change.

Why do the steps widen above 20px?

Because small type and large type are doing different jobs. Below 20px, adjacent steps sit between 1.08 to 1.14 times apart. That is a tight ladder on purpose: at interface sizes, a timestamp, a table cell and a button label sit right next to each other and have to be tellable apart without any of them shouting. A bigger ratio down there turns a toolbar into a poster.

From 20px up, the steps are 1.2 to 1.29 times apart. Display type is not competing with the label next to it; it is establishing what a page is about, often against a photograph, and hierarchy needs the air. The ladder ends at 88px, the size of the one headline that carries a page.

Line height follows the same logic in reverse. Body text is 14px on a 21px line, a ratio of 1.5, which is what the WCAG text spacing criterion asks a reader to be able to reach and the comfortable end of what readability research recommends for running text. As the type grows the ratio tightens, down to 86px on 88px at the top. Line spacing exists to help the eye find the start of the next line, and a short line of very large type needs far less help than a paragraph of small type.

How does the build enforce a design system?

With about 250 lines of TypeScript that run before every production build. The guard walks every component file and fails on four things, each of which was actually in the code rather than invented for tidiness: a font size off the scale, an arbitrary corner radius, a raw shadow where an elevation token belongs, and a hard coded hex colour that duplicates one the themed palette already provides. The whole of the type rule is one set:

scripts/check-design-tokens.ts
const TYPE_SCALE = new Set([11, 12, 13, 14, 16, 18, 20, 24, 30, 36, 44, 56, 72, 88]);

The reason it is a build failure rather than a lint warning is that warnings are read by the person who already decided to ignore them. We use the same idea for something far more serious than type: a build check that every write is role checked. A failing build cannot be ignored, and it fails at the moment the decision is being made, in the file it is being made in, with the step to use instead.

A documented preference decays in a month. A build failure does not.

Real exceptions exist, and they are allowed, with one condition: each one is written into a baseline file with a reason next to it. There are 9 entries today, 6 of them for type. Every type exception is the same kind of thing: a picture of an interface rather than an interface, such as the product mockups on the marketing site or the miniature document previews in the template gallery, where the text is drawn at reduced scale on purpose. An allowlist without reasons becomes a rubber stamp; with reasons, adding an entry means arguing for it in writing, which is usually enough to make somebody fix the file instead.

55

font sizes at e4912003, by the narrow recount

14

font sizes in the interface today, outside the exempt mockups

13

half pixel sizes before

0

half pixel sizes now

What about corner radii and shadows?

They got the same treatment for different reasons. Corner radius is a nine step scale (2, 4, 6, 8, 12, 16, 24 and 32 pixels, plus fully round), and what it fixes is nesting. A rounded thing inside another rounded thing only looks right when the inner radius equals the outer radius minus the padding between them. With dozens of arbitrary radii, that held by accident. With the scale it holds by arithmetic: a 12px card with 4px of padding holds an 8px child, and a 16px panel with 4px of padding holds a 12px card.

Shadows became five elevation steps, each a pair: a tight dark contact shadow and a wide soft one, all lit from straight above. A single blur reads as a sticker; the pair reads as an object above a surface. The same light from straight above shades the ring around every profile photo. They are also theme aware, which the one off shadows were not, so a raised card stays raised in dark mode instead of disappearing into it.

At e4912003

  • 30 distinct arbitrary radii
  • 37 distinct hand written drop shadows
  • 205 uses of Tailwind's stock shadows

Today, outside the exempt files

  • 1 arbitrary radius, a per corner shape built from scale values, and a named step everywhere else
  • 0 hand written drop shadows
  • 0 stock shadows: five elevation tokens instead

What has the type scale not fixed?

Three things, said plainly. The mockups that are exempt from the type rule still carry their own sizes, so the whole codebase, exceptions included, uses 28 sizes rather than fourteen. That is deliberate, but it is not zero. The second is buttons: the design system ships a button component with the focus ring, the disabled state and the hit target built in, and the raw element still appears 1,149 times. The guard does not check for it, because a hand written button is not wrong in the way an off scale size is, and those are moving over screen by screen. The third is one near black ink that was hard coded in many places and still needs a person to decide what it should be.

None of that changes the argument. A scale is a vocabulary, and a vocabulary only works if it is the only one available. If you want to see what the steps look like in use, the pages on this site, apart from the product mockups and the press kit, are set in them, including the pricing page and the Docs page, and the app's size classes are held to the same fourteen numbers by the same script. If you need text larger than the scale gives you, Nemi scales all of it at once; see reading comfort and accessibility.

Questions people ask

How many font sizes should a design system have?

There is no universal number, but every size should have a job, and two neighbouring sizes should be clearly different to a reader. Nemi's app uses fourteen, from 11 to 88 pixels. Before the scale existed it had 63, and nobody had chosen any of them.

What is a type scale?

A type scale is a fixed set of font sizes, each usually paired with a line height, that every screen picks from instead of choosing its own number. Nemi's steps are 11, 12, 13, 14, 16, 18, 20, 24, 30, 36, 44, 56, 72 and 88 pixels.

What ratio should a type scale use?

One ratio rarely suits both interface text and headlines. Below 20 pixels Nemi's steps sit 1.08 to 1.14 times apart, so labels, table cells and buttons can be told apart without shouting. From 20 pixels up they are 1.2 to 1.29 times apart, because display type needs the contrast to set up a page.

Should I use half pixel font sizes?

There is little reason to. At normal weights, 12.5 pixel text is hard to tell apart from 12 or 13 pixels, because hinting and antialiasing swallow the difference. What half pixels mostly produce is two labels that were meant to match and almost do, which is why Nemi uses whole pixels only.

What line height should body text have?

About one and a half times the font size is a good starting point for running text, and it is the line spacing the WCAG text spacing criterion asks that readers be able to reach. Nemi's body text is 14 pixels on a 21 pixel line. Larger type needs less, so Nemi's biggest headline sits on a line slightly shorter than its size.

How do you enforce a design system in code?

Make breaking it a build failure rather than a warning. Nemi runs a script before every production build that fails on a font size class off the scale, an arbitrary corner radius, a raw shadow or a hard coded colour the theme already provides. Real exceptions go in a list with a written reason next to each one.

Where the numbers come from

  • The scales, with the reasoning next to each: app/globals.css (THE NEMI SCALES)
  • The short version, and the counts recorded when the scale shipped: docs/DESIGN_SYSTEM.md
  • The guard that fails the build: scripts/check-design-tokens.ts, run by scripts/ci-prebuild.ts
  • The exceptions and their written reasons: scripts/design-baseline.json
  • The recount in this post: scripts/blog-bench/design-scale/count.ts, output in lib/blog/data/design-scale.json
  • The commit before the scale: e4912003, parent of dc37de4d

Read next

Use the thing we write about.

Files, docs, sheets, photos, calendar and meetings in one account.