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.

Three loose scales compared with one semantic set On the left, three independent scales for size, line height and letter spacing that a developer combines by hand. On the right, a single semantic set that bundles one correct combination of the three. Three loose scales --font-size-1 … -7 --line-height-tight … -loose --tracking-tight … -wide every combination is legal, most are wrong One semantic set --text-heading-md size 1.266rem leading 1.3 tracking −0.01em one name, one correct combination
The three properties are not independent design decisions. Bundling them removes a choice that should never have been offered.

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

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.

How leading and tracking move as size increases A plot showing leading falling from 1.65 at small sizes to 1.05 at display size, while tracking moves from positive at small sizes through zero at body to negative at display size. Both compensations move in opposite directions as size rises label body heading-md display leading ↓ tracking ↓ looser tighter
Both curves fall, but from different starting points and for different reasons. Neither is derivable from the size alone, which is why they are authored values.

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.

The failure a loose scale produces Two rendered headings compared: one using a heading size with inherited body leading, appearing over-spaced, and one using the paired heading leading, appearing correctly set. size set, leading inherited A heading that wraps onto a second line leading 1.6 — the two lines float apart paired from the set A heading that wraps onto a second line leading 1.3 — the lines read as one unit
The defect only appears when a heading wraps, which is why it survives design review and reaches production in the languages that produce longer strings.

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.