How to standardise opentype feature usage across your design system
- Step 1Audit every brand font's feature support — Run [the OpenType Features Inspector](/font-tools/opentype-features-inspector) against each font in the system — every weight and the display cut. Save each `.features.json`. Now you have a capability map: which fonts have `tnum`, `smcp`, `onum`, stylistic sets, and which only have `kern` + `liga`.
- Step 2Define the standard feature set per role — Body: `kern` + `liga` + `calt`. Data tables: add `tnum` + `lnum` for aligned, full-height figures. Legends / eyebrows: add `smcp` where supported. Display headings: optionally `dlig` and a chosen `ssNN`. Code: `kern` only — often disable `liga`/`calt` so `=>` and `!=` keep their literal characters.
- Step 3Cross-check each role against the capability map — A role token can only reference features its candidate fonts declare. If the table role wants `tnum` but the brand font's Inspector output has no `tnum` row, you either pick a different figure strategy, swap fonts for tables, or accept proportional figures — but you decide it consciously, not by silent no-op.
- Step 4Encode the decisions as design tokens — Emit one `font-feature-settings` value per role: `--features-body`, `--features-table`, `--features-display`, `--features-code`. Components reference the role token, never inline feature strings. This is where the standard becomes enforceable rather than aspirational.
- Step 5Document the why, not just the what — Next to each token, note the rationale: 'table uses tnum + lnum because dashboards align numbers; code disables liga so operators read literally'. The Inspector's labels (`Tabular figures`, `Standard ligatures`) give you the human names to use in the docs.
- Step 6Re-audit on every font update — When a foundry ships a new version, re-run the Inspector and diff the new `.features.json` against the saved baseline. A dropped `tnum` or renamed set would silently degrade your table token — catch it at update time, not in a production dashboard.
Feature matrix by typography role
A starting standard. Tags shown are the ones to pin per role; verify each is present in your fonts with the Inspector before adopting. 'Always-on by default' features (kern/liga/calt) are listed explicitly so the token is self-documenting.
| Role | Features to pin | Why |
|---|---|---|
| Body text | kern, liga, calt | The defaults, stated explicitly so the token documents intent and survives a reset |
| Data tables | kern, liga, calt, tnum, lnum | Tabular + lining figures keep columns aligned and full-height for scanning |
| Running text (editorial) | kern, liga, calt, onum | Old-style figures sit better in prose; never use onum in tables |
| Legends / labels / eyebrows | kern, liga, calt, smcp | Real small caps read as labels without shouting; only if the font ships smcp |
| Display headings | kern, liga, calt, optional dlig, chosen ssNN | Discretionary ligatures and a vetted stylistic set add character at size |
| Code / monospace | kern (disable liga, calt) | Keep =>, !=, -> as literal characters; ligatures hurt code review |
From Inspector output to a capability decision
How a font's reported feature list drives the role token. The Inspector gives the left column; you make the right.
| Inspector reports | Implication for the table role | Decision |
|---|---|---|
tnum present, lnum present | Font supports aligned, lining figures | Table token = "tnum" 1, "lnum" 1 |
tnum present, no lnum | Tabular widths but figures may be old-style | Table token = "tnum" 1; verify figure style by rendering |
No tnum | Columns will jitter with proportional figures | Swap fonts for tables, or accept jitter consciously |
smcp absent | No real small caps; text-transform fakes look wrong | Drop smcp from the legend token; use full caps or a cap-height variant |
ss01–ss20 present | Display personality available | Pick one vetted set for the display token; document its effect |
Cookbook
Patterns for encoding feature decisions as tokens once the audit is done. The Inspector supplies the capability map; these show how to turn it into CSS variables and a documented standard. Re-run the Inspector after font updates to keep the tokens honest.
Role tokens as CSS custom properties
ExampleOne font-feature-settings value per typography role, referenced everywhere. Components never inline feature strings, so the standard is enforceable.
:root {
--features-body: "kern" 1, "liga" 1, "calt" 1;
--features-table: "kern" 1, "liga" 1, "calt" 1, "tnum" 1, "lnum" 1;
--features-display: "kern" 1, "liga" 1, "calt" 1, "dlig" 1, "ss01" 1;
--features-code: "kern" 1; /* liga/calt intentionally off */
}
body { font-feature-settings: var(--features-body); }
.data-cell { font-feature-settings: var(--features-table); }
h1, h2 { font-feature-settings: var(--features-display); }
code, pre { font-feature-settings: var(--features-code); }Guarding a token against a missing feature
ExampleBefore the table token relies on tnum, confirm it. If the Inspector's JSON has no tnum row, the CSS no-ops silently and columns jitter in production. Verify in the audit, not at runtime.
# Audit step (saved from the Inspector): # brand-sans.features.json → has tnum, lnum ✓ # brand-serif.features.json → NO tnum ✗ # # Therefore: tables MUST use brand-sans, never brand-serif. # Documented in the table token's comment: --features-table: "kern" 1, "tnum" 1, "lnum" 1; /* Requires Brand Sans — Brand Serif lacks tnum (audited 2026) */
Code role with ligatures deliberately off
ExampleProgramming fonts often ship liga/calt that fuse operators into glyphs. In code review and diffs you usually want literal characters. Pin the decision so nobody re-enables it by accident.
.editor, code, pre, kbd {
font-family: "Brand Mono", monospace;
/* calt off keeps => as = >, != as ! =, -> as - > */
font-feature-settings: "kern" 1, "liga" 0, "calt" 0;
}
/* If a team wants ligatures, they opt IN on a scoped class — */
/* the default stays literal. */
.ligatures-on { font-feature-settings: "kern" 1, "liga" 1, "calt" 1; }Old-style figures for prose, lining for data
ExampleEditorial running text reads better with onum; data tables need lnum. Two tokens, audited against the same font's Inspector output.
/* Inspector confirmed brand-serif has both onum and lnum */
.article-body { font-feature-settings: "kern" 1, "liga" 1, "onum" 1; }
.stat-block { font-feature-settings: "kern" 1, "tnum" 1, "lnum" 1; }
/* Equivalent high-level aliases, if you prefer readability: */
.article-body { font-variant-numeric: oldstyle-nums; }
.stat-block { font-variant-numeric: tabular-nums lining-nums; }A capability map committed to the repo
ExampleDistill every font's Inspector JSON into one small file the whole team reads. It's the source of truth for which features a role token may reference.
// design-system/font-features.map.json
{
"brand-sans": ["kern","liga","calt","tnum","lnum","smcp","ss01","ss02"],
"brand-serif": ["kern","liga","calt","onum","lnum","frac"],
"brand-mono": ["kern","liga","calt","zero"],
"brand-display": ["kern","liga","calt","swsh","ss01","ss02","ss03","titl"]
}
// Each list is the `features[].tag` set from that font's
// .features.json, captured by the OpenType Features Inspector.Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Token references a feature the font lacks
Silent no-opfont-feature-settings: "tnum" 1; on a font with no tnum does nothing — no error, no warning, columns just jitter. This is why the audit step matters: only let a role token reference features the Inspector confirms across every font that role can render. Treat the Inspector's per-font features[].tag set as the contract.
A second feature rule wipes the first
Override trapfont-feature-settings is replaced, not merged, by a later declaration on the same element. A component that sets "smcp" 1 and then inherits a base rule setting "tnum" 1 ends up with only tnum. Tokens solve this by making every role a single complete declaration; never let two font-feature-settings rules race on one selector.
Different weights expose different features
Audit each weightA family's Regular might ship smcp while its Light does not, or a variable font might gate features behind axis positions. Run the Inspector on every weight/cut you actually ship, not just Regular. A token that assumes uniform support across the family can silently degrade on the weight that lacks the feature.
Faking small caps with text-transform
AvoidIf the font has no smcp, text-transform: uppercase plus a smaller font-size is not small caps — the strokes are too thin relative to the caps and it reads as shrunken capitals. The Inspector tells you whether real smcp exists; if it doesn't, choose full caps with letter-spacing or pick a font that ships small caps rather than faking it.
calt disabled breaks complex-script shaping
CautionTurning calt off for a code role is fine for Latin, but calt (and rclt, rlig) are load-bearing for correct shaping in Arabic, Indic, and other complex scripts. If a multilingual product reuses the same code token for non-Latin text, disabling contextual features can mangle the script. Scope the disable to Latin-only contexts.
Foundry update drops a feature
RegressionRare but real: a font update simplifies and removes a feature your design system relied on. Without a baseline you find out from a bug report. With saved .features.json baselines you diff the new Inspector output against the old one in the same PR that bumps the font and catch the dropped tag before merge.
Cargo-culted feature strings across components
Cleanup targetInline font-feature-settings: "kern" 1, "liga" 1, "calt" 1 copied into dozens of components is the smell this whole approach fixes. It's redundant (those are defaults), inconsistent (some components miss one), and unauditable. Replace every inline string with a role token; grep the codebase for font-feature-settings to find the stragglers.
Variable-font features vs axes confused in tokens
Two different propertiesDesigners sometimes try to set a weight via font-feature-settings — it does nothing, because weight is an axis (fvar), not a feature. Features go in font-feature-settings, axes in font-variation-settings. The Inspector reports features only; for axes, freeze or inspect with the variable font freezer. Keep the two token families separate.
Frequently asked questions
Why pin tnum for data tables?
By default fonts use proportional figures — each digit has its own width — so columns of numbers don't line up vertically and the eye can't scan them. Tabular figures (tnum) force every digit to the same advance width so columns align perfectly. It's essential for dashboards, financial tables, and timetables. Pair it with lnum (lining figures) so the numbers are full cap-height rather than old-style. Audit the font with the Inspector first — tnum on a font that lacks it silently does nothing.
What's the difference between lnum and onum?
lnum (lining figures) are all the same height as capitals — 1234567890 sit on the baseline at cap height. onum (old-style figures) have ascenders and descenders, so 3, 4, 5, 7, 9 dip below the baseline — they blend into running prose. Rule of thumb: lnum for tables and UI, onum for editorial body text. A font may ship both; the Inspector lists each as its own row.
Should I expose features to designers or hard-code them?
Expose them as documented role tokens — --features-table, --features-body — that designers choose between deliberately. Avoid hard-coding feature strings inline in component CSS, which produces the cargo-cult where every component just repeats 'kern liga calt' with no rationale. Tokens with descriptive names plus a documented reason prevent drift and make the decisions reviewable.
How do I audit which features my brand fonts support?
Run each font through the OpenType Features Inspector. It returns JSON listing every GSUB/GPOS tag the font declares with human labels. Save the .features.json per font and distill them into a capability map. That map is the contract: a role token may only reference features the map confirms for every font that role can render.
Do I need to pin kern, liga, and calt if they're on by default?
Functionally no — but pinning them in the token makes intent explicit and survives a CSS reset that might disable them. More importantly, stating them documents your baseline so the deliberate exceptions (code roles disabling liga/calt) read as intentional rather than as someone forgetting. Self-documenting tokens are worth the few extra bytes.
Why disable ligatures for code?
Programming-font ligatures fuse =>, !=, ->, === into single glyphs. That looks elegant in a demo but hurts code review, diffs, and character counting where you need to see the literal characters. Pin "liga" 0, "calt" 0 on the code role and let teams opt in to ligatures on a scoped class. Keep the literal-character default.
Can a token reference a feature only some weights have?
Not safely. If Regular has smcp but Light doesn't, a token that assumes smcp degrades silently on Light. Run the Inspector on every weight and cut you ship, and either restrict the feature to roles that only use supporting weights, or drop it. Variable fonts can also gate features by axis position — audit them at the positions you actually instance.
How do I keep the standard from rotting after a font update?
Keep each font's .features.json baseline in the repo. When you bump a font version, re-run the Inspector and diff the new output against the baseline in the same PR. A dropped or renamed feature shows up immediately, so a foundry simplification can't silently break your table or legend tokens. Treat the diff as a required review step.
Are font-variant-* aliases better than font-feature-settings for tokens?
For the features that have aliases (font-variant-numeric: tabular-nums, font-variant-caps: small-caps), they're more readable and self-documenting. But ss01–ss20, swsh, and many others have no alias, so a real design system mixes both: aliases where they exist, font-feature-settings for the rest. The Inspector emits the low-level form for everything, which you can translate to aliases where one applies.
Do extra features the foundry includes but we don't use hurt us?
Barely. Unused features add a little file weight (the alternate glyphs plus GSUB rules) but cost nothing at render time if you never enable them. If a font ships heavy display features you'll never use on the web, you can strip them with the font subsetter — but only after confirming via the Inspector that no role token references them.
How does this relate to a type scale or CSS variables?
Feature tokens are one layer of the typography system; size and rhythm are another. Pair this with the typography scale builder for a modular size scale and the CSS variable generator to emit the full set of font custom properties. Features answer 'which glyph variants'; the scale answers 'how big' — keep them as separate, composable token families.
Is the audit safe to run on licensed brand fonts?
Yes. The OpenType Features Inspector parses every font in your browser with opentype.js and shows a '0 bytes uploaded' badge — the file never reaches a server. So you can audit a full set of licensed brand fonts, including unreleased cuts, without any of them leaving your machine.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.