Tokenizing Line-Height and Letter-Spacing Pairs
Part of Typography Scale Systems. Three independent scales — size, leading and tracking — produce nine hundred possible combinations, of which about eight are correct. This guide bundles them so the correct ones are the only ones reachable.
Prerequisites Link to this section
- A modular size scale, as built in building a modular type scale.
- A decision on the semantic type roles the system publishes — display, heading levels, body, label, code.
- Access to the component CSS, since the payoff comes from components consuming sets rather than individual properties.
- Optionally, a Stylelint setup for the enforcement step.
Step-by-Step Implementation Link to this section
Step 1 — Define the set as three related tokens Link to this section
Intent: keep the three values addressable individually while making the grouping explicit in the name.
{
"text": {
"heading-md": {
"size": { "$value": "1.266rem", "$type": "dimension" },
"leading": { "$value": "1.3", "$type": "number" },
"tracking": { "$value": "-0.01em", "$type": "dimension" }
},
"body": {
"size": { "$value": "1rem", "$type": "dimension" },
"leading": { "$value": "1.6", "$type": "number" },
"tracking": { "$value": "0", "$type": "dimension" }
}
}
}
Why this works: the DTCG format has a composite typography type, and using three scalar tokens under a shared group instead keeps each value independently typeable and referenceable — which matters for @property registration, for the contrast-adjacent checks that need a numeric size, and for platforms that consume the values separately. The grouping carries the relationship without giving up the granularity.
Step 2 — Emit a single utility per set Link to this section
Intent: components should apply one thing, not three.
/* Generated from the token set. */
.text-heading-md {
font-size: var(--ds-text-heading-md-size);
line-height: var(--ds-text-heading-md-leading);
letter-spacing: var(--ds-text-heading-md-tracking);
}
.text-body {
font-size: var(--ds-text-body-size);
line-height: var(--ds-text-body-leading);
letter-spacing: var(--ds-text-body-tracking);
}
Or, where components prefer properties over classes:
.card-title {
font: var(--ds-text-heading-md-leading) var(--ds-text-heading-md-size) / …;
}
Why this works: the generated class is the enforcement mechanism as much as a convenience. A component that applies .text-heading-md cannot accidentally pair a heading size with body leading, because the pairing is not expressed at the call site at all. Generating these from the token file means adding a role produces its utility automatically.
Step 3 — Choose leading by role, not by a global rule Link to this section
Intent: the relationship between size and leading is not linear, and a single ratio applied across the scale produces cramped headings or airy captions.
| Role | Size | Leading | Why |
|---|---|---|---|
| display | 3rem | 1.05 | Large type needs almost no extra leading; 1.5 would look disconnected |
| heading-lg | 2rem | 1.15 | Still tight, but enough to separate two wrapped lines |
| heading-md | 1.27rem | 1.3 | Transitional — headings here often wrap in narrow columns |
| body | 1rem | 1.6 | Reading text needs generous leading for line tracking |
| body-sm | 0.875rem | 1.65 | Smaller text needs proportionally more, not less |
| label | 0.75rem | 1.4 | Short strings, rarely wrapping; leading matters less than tracking |
Why this works: leading requirements fall as size rises, which is the opposite of what a fixed ratio produces. Encoding the relationship as a table of role values rather than as a formula is more honest — this is a set of typographic judgements, not a derivation — and it makes each value reviewable on its own.
Step 4 — Set tracking negative for large sizes, positive for small Link to this section
Intent: compensate for how the typeface’s spacing reads at different sizes.
:root {
--ds-text-display-tracking: -0.02em; /* large: tighten */
--ds-text-heading-lg-tracking: -0.015em;
--ds-text-body-tracking: 0; /* the design's natural spacing */
--ds-text-label-tracking: 0.02em; /* small caps-adjacent: open up */
}
Why this works: most text faces are optically spaced for reading sizes. Set much larger, the same spacing reads as loose; set much smaller, as tight. A tracking token per role is where that compensation belongs, and expressing it in em means it scales with the size it accompanies rather than needing recalculation.
Step 5 — Enforce the pairing with a lint rule Link to this section
Intent: stop a component from setting a size without the leading that belongs with it.
// stylelint-plugin-ds/paired-typography.js
const stylelint = require('stylelint');
const ruleName = 'ds/paired-typography';
const messages = stylelint.utils.ruleMessages(ruleName, {
unpaired: () =>
'font-size set without a matching line-height. Apply a text role utility ' +
'(e.g. .text-heading-md) or set both from the same token set.',
});
module.exports = stylelint.createPlugin(ruleName, (enabled) => (root, result) => {
if (!enabled) return;
root.walkRules((rule) => {
let size = null, leading = false;
rule.walkDecls('font-size', (d) => { size = d; });
rule.walkDecls('line-height', () => { leading = true; });
rule.walkDecls('font', () => { leading = true; }); // shorthand includes it
if (size && !leading) {
stylelint.utils.report({ ruleName, result, node: size, message: messages.unpaired() });
}
});
});
Why this works: a rule set on font-size alone is the exact shape of the defect — it inherits whatever leading the parent had, which is almost never right for the new size. Catching it at the declaration level is far more reliable than reviewing rendered output, where a slightly wrong leading is easy to overlook.
Step 6 — Handle the responsive case without breaking the pairing Link to this section
Intent: a fluid size must bring a matching fluid leading, or the relationship breaks at one end of the range.
.text-heading-md {
font-size: clamp(1.125rem, 1rem + 0.6vw, 1.5rem);
/* Unitless leading multiplies the resolved size, so the ratio holds at every width. */
line-height: 1.3;
letter-spacing: -0.01em;
}
Why this works: a unitless line height is a multiplier applied to the computed font size, so it tracks a fluid size automatically. A line height expressed in rem or px does not, and a heading that is fluid in size with a fixed leading becomes progressively cramped as it grows. This is the single strongest argument for unitless leading in a system that uses fluid type at all.
Verification Link to this section
Render every role at the narrowest and widest supported widths and confirm two things: no role’s lines touch or overlap, and no role’s leading looks disconnected from its size. Both are visual checks, but they can be narrowed by computing the resolved line box height against the font size in the browser and asserting the ratio stays inside a range per role.
The automatable check that matters most is the pairing rule from step 5 running across the whole codebase, and a second assertion that every published role has all three tokens defined. A role missing a tracking token silently inherits whatever tracking was in effect, which is the same class of defect the bundling was meant to eliminate.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Headings look loose only in some languages | The heading wraps there and inherits body leading | Apply the paired role utility; the lint rule catches this class |
| A fluid heading becomes cramped at large widths | Line height is expressed in a fixed unit | Use a unitless multiplier so leading tracks the resolved size |
| Tracking looks wrong after a typeface change | Tracking values compensate for a specific face’s spacing | Re-derive tracking per role when the typeface changes; it is not portable |
| The lint rule fires on a legitimate rule | A rule that sets only font-size to inherit an intentional leading |
Set both explicitly; intentional inheritance is indistinguishable from the defect |
| Two roles look identical | Their sizes differ by less than a perceptible step | Consolidate the roles; the scale has more steps than the design uses |
Migration Note Link to this section
Consolidating three loose scales into semantic sets is mostly a search-and-replace once the sets are defined, and the interesting work is in deciding how many roles there should be. Start by extracting every distinct combination of size, leading and tracking currently used in the codebase — the count is usually between fifteen and forty, and clustering them down to six or seven roles is the actual design exercise.
Record which original combination mapped to which role, exactly as with elevation tokenisation. That record is what allows a designer to challenge a specific merge — “the card title and the section heading are not the same thing” — rather than the whole consolidation, and it is what makes the change reviewable at all.
What Not to Bundle Into the Set Link to this section
The three properties covered here belong together because they are jointly determined by the role. It is worth being clear about what does not belong in the same bundle, since the temptation to keep adding is strong.
Font family does not belong. A heading and a body role may share a family or not, and the decision changes independently — a brand swap replaces the family across every role at once, which is a single token, not seven. Bundling it means a family change touches every role definition.
Colour does not belong either. Text colour is determined by the surface the text sits on and by the
theme, both of which vary independently of the role. A text-heading-md set that included a colour
would need a variant per surface, which multiplies the roles by the surfaces for no benefit.
Weight is the interesting borderline case. It is genuinely part of the role’s identity — a heading is usually bolder — but it also varies within a role for emphasis. The workable rule is to include a default weight in the set and allow it to be overridden locally, which is different from the other three properties where a local override is almost always a mistake.
Margin certainly does not belong, though it is frequently proposed. Vertical rhythm is a layout concern determined by what precedes and follows the text, not by the text’s own role, and baking a margin into a type utility produces components that fight it with negative margins.
Related Link to this section
- Typography Scale Systems — the parent area, including how many roles a system needs
- Building a Modular Type Scale with Custom Properties — generating the size scale these sets consume
- Mapping Typography Tokens to CSS clamp Functions — the fluid case, where unitless leading becomes load-bearing