Design Archive
中EN

One font size, one line height

An input and the button next to it had the same font size but stood 4px apart in height. The cause was one size carrying two line heights in the type scale.

L.06 / 14
CATEGORY
Study
YEAR
2026
STATUS
Note
TOOLS
figma / variables

entryWrite-up

I look after the design system of a Chinese-language web platform. Its pages come in two kinds: browsing pages such as lists, details and long articles, and task pages such as forms and searches. The set of preset font sizes and line heights in a design system is called the type scale; this one had 16 steps, split by purpose into 6 groups (display, heading, body, supporting, control and data), and each group set its own line heights. At first I only meant to compare it with the type scale of a popular open-source component library and see what could be improved. The counts, comparisons and batch edits were all done by scripts that read and wrote the design file directly through Figma’s plugin API.

One font size had two line heights. Size 14 was 14/24 in the body group and 14/22 in the control group; size 16 was 16/28 and 16/24 (16/28 means a 16px font on a 28px line). In a table these are just two numbers; on the same row they fall out of line. An input field used the body style and the button beside it used the control style; both shared the same padding, and the text’s midline sits at the top padding plus half the line height. Four extra pixels of line height make the control 4px taller and drop the text by 2px. As a result, in the size-16 pair the input was 4px taller than the button and the midlines were 2px apart; at 14 the gaps were 2px and 1px. In design files this kind of offset tends to get nudged away by hand, and then it comes back in another form during development.

Before the rework the two midlines form a step; after, they line up. The values were measured by the demo page's script.

Semantic names grouped by purpose stay close to the product and are easy to pick, and at first that was counted as a strength of the old scheme. Tested against the misaligned input and button, whether a piece of 14px text counted as body or as control turned out to be more than naming: it changed the layout. Before picking a style, a designer had to work out what stood next to the text; semantic names brought not convenience but one more decision.

The rework had three proposed directions: drop purpose-based names; give each font size one line height; consider adding 13 and 15px sizes. On the third, the steps from 12 to 14 and 14 to 16 were already only 14–17%; another step in between would leave 7–8%, too small for the eye to tell apart, and would only add one more “which one should I pick”. The extra compactness wanted here was really a density problem, which belongs to padding; in the open-source library used for comparison, the three density levels of the table use the same font size and differ only in cell padding. In the end the first two were adopted and the third dropped. From 12 to 20, line heights were unified: 14 became 22, 16 became 24, and the rest stayed. Headings, display and data styles from 24 up each have a size of their own, never sit beside controls, and stay outside this rule. Names became plain numbers, such as 16/Medium, so the name is the spec; this rests on my having confirmed that the platform would not get a large-text mode, otherwise the numbers in the names would be wrong as soon as the mode switched. After unifying, three pairs of styles became identical field by field (“Button 16” and “Body emphasis 16”, for instance, were both 16/24/Medium), so buttons did not need a size of their own and the three duplicates were deleted.

Long articles on detail pages read well at a loose line height of 1.75. That is a real need, and the rule should not flatten it. At first only a 16 paragraph style was planned; a 14 one was added later, and two paragraph styles were kept: 14/Paragraph (14/24) and 16/Paragraph (16/28). Both carry Paragraph in their names, and their descriptions say they are for running text only and must never share a row with controls.

To check whether the line heights ran smoothly across the whole scale, a ratio was introduced: line height divided by font size, the line space each 1px of font size gets. That ratio should fall monotonically as size grows. After each line the eye returns to the start of the next: at small sizes a line holds many characters and the return is long, so it needs more relative space to guide it; at large sizes a line holds only a few characters, and the same ratio looks loose. Drawing three stages on one chart (before the rework, one line height per size, and after making it monotonic) changed the priorities. The written discussion had kept returning to a bump at 24, which sat 0.017 above its neighbors. The chart showed that before the rework the curve rose between 12 and 16, with 14 and 16 sitting 0.25–0.30 above the trend: the largest deviation anywhere, an order of magnitude worse than 24. That also explained why the alignment problem showed up at exactly 14 and 16, the only two sizes far from the curve. The first step, unifying the line heights, had already pulled the first half back onto the curve; the remaining problems were in the second half: the bump at 24, and 30 having the same ratio as 20 despite being 10px larger.

The ratio curve in three stages: before the rework, one line height per size, and after making it monotonic.

The line height for 24 had to be either 32 or 34. Common practice gave 32, for three reasons: measured, every use of that size was a single line, so line height affected only how tidy the layout was, not reading; 32 sits on the 4px grid; and it matched the same step in the comparison library. But changing 24 alone would turn the bump into a dip: the 1.400 at 30 was itself high, and once 24 came down it would sit lower than both neighbors. Problems on a curve have to be looked at along the whole curve, not fixed point by point. In the end 24, 30, 36 and 48 were adjusted together, after which the ratio fell all the way down without turning back. The two data sizes, 28 and 40, deliberately use tighter line heights: large figures are always one line and play no part in guiding the eye, so they stay off the curve. When a step is added or changed, its new value goes onto this curve first: a point higher than both neighbors, or lower than both, is a value set wrong.

The ratio table; orange marks where the curve turned back.

With the paragraph styles in place, the real long-form text had to move to them. The first pass picked candidates automatically as text running to 2 or more lines, and found 108. But an input can hold several lines too: does that count as a paragraph? On review, the vast majority of the 74 two-line cases were card descriptions and footer notes, not long text. Running to several lines is only a matter of form; the test then changed to the role of the container: whether it is a block of running text that can be read on its own. Text inside a control uses the default style however many lines it has. In the end only 7 places moved to a paragraph style. Abstract rules need testing against concrete cases: the misaligned input and the text box that takes several lines each overturned a judgment once.

A second check found that of the first 12 paragraph candidates, 5 sat in archived areas no longer in use. The filter that excluded archives had only looked 3 levels up the parent chain, while the archive sat at level 5. Only once the filter checked all the way up to the page root were they cleared out.

A scan of all 12px text found no bold use, which led to a suggestion that no 12 Medium style was needed. I insisted on adding it, and later a real case turned up in a sentence like “186 pieces of feedback this period”: the number in bold, the rest regular. Mixed weights inside a run are invisible to a scan that looks at whole paragraphs.

Rules written in a document get forgotten sooner or later. To lock the rule in, font size, line height, weight and family all became Figma design variables (values that can be bound to style properties), and line-height variables are named in pairs with sizes: font/size/14 has a single matching font/lineHeight/14, and the paragraph variables carry paragraph in their names. So in the variables panel, size 14 has only one line height with a matching name, leaving no room to pick the wrong one; using a paragraph style means deliberately choosing the one with paragraph in its name. That made 28 variables; the 16 styles each bind 4 fields (family, weight, size and line height), for 64 bindings in all. Reading every style back after binding showed no change in displayed values: only the source of the numbers changed, not the numbers themselves.

Merging duplicate styles ran into local overrides on component instances (style settings kept separately on an instance): the main component was fixed, but 100 instances kept their old styles. The script was changed to edit the nodes inside the instances directly.

Binding the font-family variable makes every text layer that uses it resolve its font again. Along the way, one fallback symbol font (the font the symbols in the copy fell back to) was not loaded, so the whole batch of bindings failed. The script read the font name from the error, loaded it and retried.

The final rule: from 12 to 20, line height is set by font size alone and purpose only affects weight; from 24 up each size stands alone; the only exceptions are the two Paragraph styles, and multi-line input inside a control does not count as a paragraph. There were still 16 styles: 3 duplicates removed, and 12 Medium plus the two paragraph styles added. About 3200 text layers updated automatically; of the 2643 layers affected, 2620 sat inside auto layout (Figma containers that resize and arrange themselves with their contents) and reflowed by themselves, 99.1%; 1 fixed-height chat bubble had to be fixed by hand; nothing was truncated. The type specimen board is now generated from the same data too, and its parameter column is read from the styles rather than typed by hand. The design files are built with auto layout as a matter of course, so that when font sizes and line heights change, the layers can reflow on their own.

processBefore & after

The type specimen board, generated from the same data; the sample copy is neutral.

Final scale (size / line height · ratio · use)

12/Regular · Medium
12 / 20 · 1.667 · Supporting text, status tags
14/Regular · Medium
14 / 22 · 1.571 · Lists, tables, forms, buttons
14/Paragraph
14 / 24 · 1.714 · Exception: long-form paragraphs
16/Regular · Medium
16 / 24 · 1.500 · Single-line body, card titles, large buttons
16/Paragraph
16 / 28 · 1.750 · Exception: long-form paragraphs
18/Medium
18 / 26 · 1.444 · Subheadings
20/Medium
20 / 28 · 1.400 · Card titles, values in tables
24/Medium
24 / 32 · 1.333 · Section headings
28/Bold
28 / 36 · 1.286 · Data
30/Medium
30 / 40 · 1.333 · Column headings
36/Bold
36 / 46 · 1.278 · Page titles
40/Bold
40 / 48 · 1.200 · Data
48/Bold
48 / 60 · 1.250 · Hero headings

Results

Styles
16 → 16 (3 duplicates removed; 12 Medium and two paragraph styles added)
Text layers updated automatically
About 3200
Risk scan
2620 of 2643 affected layers reflowed automatically (99.1%) · 1 fixed by hand · 0 truncated
Design variables
28 · 64 bindings · no change in displayed values

resultTakeaways

Finding
From 12 to 20px, line height now depends only on font size, and the line-height ÷ size ratio falls steadily as size grows. About 3200 text layers followed automatically; of the 2643 affected layers, 99.1% reflowed on their own.
Parameters
14 → 22 · 16 → 24 · 24 → 32 · paragraph styles 14/24, 16/28 · 28 variables · 64 bindings
Open
Style names now contain the numbers, which assumes the platform never gets a large-text mode. If it does, the naming has to be redone.

logChangelog

2026-10
First entry

Related experiments

note
L.07(STUDY)2026

Centering icons and Chinese text for real

typefonttoolsfigma+1
note
L.10(STUDY)2026

Getting 124 light pillars to line up before they rise

motionfigmamcp+1
note
L.01(STUDY)2026

(1) Choosing a base model

chest-xraypytorchrad-dino+1
← LabL.06 / 14
1 / 1

100%