9 min read

Typography tokens for design systems: names, scales, and implementation

How to name typography tokens, map Figma styles to CSS variables, and keep responsive type usable across design and code.

Tokens are decisions, not variables

A typography token is not a CSS variable with a nicer name. It is a decision that somebody already made — which size means "page title", which line-height a dense table row gets, how much a caption may shrink — recorded in a place both design and code can read. The variable is the delivery mechanism. The decision is the product.

That distinction explains why token systems fail in two opposite ways. Too few tokens and everyone improvises: a designer nudges a heading to 27px because it looked better on that one screen, and six months later the product has fourteen heading sizes nobody chose. Too many tokens and nobody can choose at all: a list of forty semantic names produces the same improvisation, just with more scrolling.

A working system sits in the middle, and you can usually tell within a week of shipping it. The signs are unglamorous and practical.

  • A developer can pick the right token from the name alone, without opening Figma.
  • A designer can name the style they used in a review comment and everyone knows what CSS it maps to.
  • Changing the scale is one edit in one file, not a search-and-replace across components.
  • New screens get built from existing tokens; new tokens are a deliberate, rare event.

Name tokens for use, not for size

The fastest way to break a token system is to name tokens after their values. text-18 is honest on the day it ships and a lie the first time the scale changes — because the token that renders 20px is still called text-18, and now every developer has to decide whether to trust the name or the value. Worse, with fluid typography the value is a range, so a size-based name was never accurate to begin with.

The way out is two layers. A primitive layer holds the raw steps of the scale and may be numbered, because those names are internal plumbing: --font-size-100 through --font-size-800. A semantic layer sits on top and describes use: --text-display, --text-heading-lg, --text-body, --text-caption. Product code only ever touches the semantic layer. When the scale changes, the primitives move and the semantic names keep meaning what they meant.

Keep the semantic list short enough to hold in your head. Five to eight roles cover most products; anything beyond that is usually a component-specific need that belongs to the component, not to the global scale.

  • Name by role: heading, body, caption, label, code, display.
  • Use sm / md / lg for steps within a role, never pixel values.
  • Keep numeric names in the primitive layer only, where they never appear in product code.
  • Never encode a viewport in a name — --text-body-mobile is a media query pretending to be a token.
:root {
  /* Primitives — the scale. Numbered on purpose: internal plumbing. */
  --font-size-100: clamp(0.875rem, 0.84rem + 0.18vw, 0.9375rem);
  --font-size-200: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem);
  --font-size-400: clamp(1.25rem, 1.14rem + 0.55vw, 1.5rem);
  --font-size-600: clamp(1.75rem, 1.45rem + 1.5vw, 2.5rem);
  --font-size-800: clamp(2.25rem, 1.6rem + 3.25vw, 4rem);

  /* Semantic — what product code uses. */
  --text-caption:    var(--font-size-100);
  --text-body:       var(--font-size-200);
  --text-heading-sm: var(--font-size-400);
  --text-heading-lg: var(--font-size-600);
  --text-display:    var(--font-size-800);
}

Map Figma styles to variables one to one

The strongest handoff is boring: the Figma text style, the token name, and the CSS variable are the same string. If a designer applies heading/lg, the developer writes var(--text-heading-lg) and nobody translates anything. Every translation step is a place for the system to drift, and drift in typography is invisible until it is everywhere.

Figma variables and variable collections make this practical, because the names can be exported rather than transcribed. The workflow that holds up is: text styles in Figma reference typography variables, those variables are exported as JSON, and the JSON is the input to whatever generates CSS. Nobody retypes a number.

The one thing Figma cannot express is the fluid range. Figma styles are fixed sizes at a design viewport, so the export gives you a number where production needs a range. That conversion is its own step — take the design-viewport size as the upper anchor, decide the small-viewport size deliberately, and generate the clamp() from both. The full method is in how to convert Figma font sizes to CSS clamp tokens, and the TYPECLAMP generator does the arithmetic and exports the result as CSS, Tailwind, SCSS, Style Dictionary, or JSON.

{
  "text": {
    "heading-lg": {
      "$type": "typography",
      "$value": {
        "fontFamily": "{font.family.sans}",
        "fontSize": "{font.size.600}",
        "fontWeight": "{font.weight.semibold}",
        "lineHeight": "1.2",
        "letterSpacing": "-0.02em"
      }
    }
  }
}

A type token is a composite, not a number

Most token systems start as a list of font sizes and stay that way, which quietly pushes every other decision back into component CSS. A heading is not a size; it is a size, a line-height, a weight, and usually a letter-spacing that all belong together. If only the size is tokenised, each component re-decides the rest, and the fourth developer to build a card picks line-height: 1.4 because it looked fine.

Line-height is the part that suffers most from being left out, because its correct value depends on the size it accompanies: large display text needs a tighter multiplier than body copy, and a token that ships the size without the multiplier guarantees the pairing gets rebuilt by hand. It also behaves less predictably than most people expect — the details are in why line-height is harder on the web than it looks. Ship the pair.

Letter-spacing deserves the same treatment for a subtle reason: the optical tracking a typeface wants changes with size. Tight tracking that looks sharp on a 64px display line makes 14px labels feel cramped. Bake the pairing into the token rather than leaving it to whoever is building the screen — and note that the right values depend on the typeface, which is one more reason to pick fonts with the checks in choosing web fonts for real interfaces before locking a scale.

:root {
  --leading-tight:  1.15;
  --leading-snug:   1.3;
  --leading-normal: 1.55;
}

/* Composite tokens as utility classes — one decision, one place. */
.text-display {
  font-size: var(--text-display);
  line-height: var(--leading-tight);
  font-weight: var(--weight-semibold);
  letter-spacing: -0.02em;
}

.text-body {
  font-size: var(--text-body);
  line-height: var(--leading-normal);
  font-weight: var(--weight-regular);
  letter-spacing: 0;
}

Responsive tokens need documented intent

A fixed token is self-explanatory. A fluid one is not: clamp(1.75rem, 1.45rem + 1.5vw, 2.5rem) looks like three magic numbers, and six months later nobody remembers whether the 2.5rem was a considered maximum or an accident. The result is predictable — someone "fixes" it by nudging the middle term until one breakpoint looks right, and breaks the other end of the range.

The fix costs almost nothing: record the intent next to the value. Four facts are enough — the minimum viewport, the maximum viewport, the size at each end, and how line-height behaves across the range. With those four, the clamp expression becomes a derived value that anyone can regenerate rather than a number that must be preserved.

If your tokens live in JSON, the same information belongs in the file rather than in a comment that only exists in the CSS build output. The Design Tokens format has an $extensions field for exactly this kind of tool-specific metadata.

Two rules keep a fluid scale honest in practice: use rem at both ends so browser zoom and user font-size settings still work, and check that the ratio between adjacent steps stays sensible at both ends of the range — scales that look balanced at 1440px often collapse into near-identical sizes at 360px. The reasoning behind both is in responsive typography with CSS clamp: rules that hold up.

/*
  --text-heading-lg
  320px → 28px   |   1440px → 40px
  line-height: unitless 1.2 (scales with size)
  Regenerate, don't hand-edit: typeclamp.com
*/
:root {
  --text-heading-lg: clamp(1.75rem, 1.45rem + 1.5vw, 2.5rem);
}

{
  "text": {
    "heading-lg": {
      "$type": "dimension",
      "$value": "clamp(1.75rem, 1.45rem + 1.5vw, 2.5rem)",
      "$description": "Section headings. Fluid between the documented viewports.",
      "$extensions": {
        "com.typeclamp": {
          "minViewport": 320,
          "maxViewport": 1440,
          "minSize": 28,
          "maxSize": 40,
          "designViewport": 1440
        }
      }
    }
  }
}

One source, many outputs

The last failure mode is duplication. Tokens defined once in CSS, again in the Tailwind config, again in a native app's constants file, and now three places drift apart at three different speeds. The way out is to treat one machine-readable file as the source and generate everything else from it, so that changing a value is one commit rather than an audit.

Style Dictionary is the common tool for this, and Tailwind v4 makes the CSS side unusually direct: the @theme block is itself the token declaration, so generated custom properties become utility classes without a config file in between. The Tailwind-specific details — naming rules the generator expects, how the utilities are derived, and where the fluid values go — are covered in Tailwind v4 typography tokens with CSS clamp.

What is left is governance, and it is shorter than it sounds. Decide who may add a semantic token and make that a review, not a pull request anyone can merge. Deprecate rather than delete, so old branches do not break silently. And write down the scale's intent — the ratio, the viewport range, the anchor size — somewhere a new hire will find it, because a scale whose logic is undocumented becomes a list of arbitrary numbers within a year.

None of this is exciting work, and that is the point. A typography token system is successful when nobody thinks about it: designers reach for a named style, developers reach for a named class, and the scale changes in one place when it needs to. Everything else — the drift, the fourteen heading sizes, the magic numbers — is what happens when the decisions were never written down.

  • Name by role in the semantic layer; keep numbers in the primitives.
  • Match Figma style names to token names exactly — no translation step.
  • Ship size, line-height, weight, and tracking together as one token.
  • Document min/max viewport and min/max size for every fluid value.
  • Generate CSS, Tailwind, SCSS, and JSON from a single source file.
  • Make adding a semantic token a decision, not a convenience.
/* app.css — Tailwind v4 */
@import "tailwindcss";

@theme {
  --text-caption:    clamp(0.875rem, 0.84rem + 0.18vw, 0.9375rem);
  --text-body:       clamp(1rem, 0.96rem + 0.2vw, 1.0625rem);
  --text-heading-lg: clamp(1.75rem, 1.45rem + 1.5vw, 2.5rem);
  --text-display:    clamp(2.25rem, 1.6rem + 3.25vw, 4rem);

  --leading-tight:  1.15;
  --leading-normal: 1.55;
}

/* Usage: class="text-heading-lg leading-tight" */