How to set ligature css conventions for your design system
- Step 1Set the body / root default — Pin the global default on `:root` or `body`: `font-feature-settings: "liga" 1, "calt" 1, "kern" 1;`. This is the modern browser default made explicit so nothing inherits a surprise. Equivalent semantic form: `font-variant-ligatures: common-ligatures contextual;`.
- Step 2Override code and pre to off — `code, pre, kbd, samp { font-feature-settings: "liga" 0, "calt" 0; }`. Because the property inherits, this single rule disables ligatures for every nested token, comment, and string — exactly what you want for character-accurate code.
- Step 3Decide the headline rule — but verify dlig first — Before writing `h1, h2 { ... "dlig" 1 }`, drop the display font into the [Ligature Toggler](/font-tools/ligature-toggler) and look at row 3 (discretionary on). If row 3 differs from row 2, the font has `dlig` glyphs and the rule is worth it. If they match, drop `dlig` from the token — it is dead CSS.
- Step 4Verify the feature actually exists in the font — For a definitive answer (not just a visual one), run [opentype-features-inspector](/font-tools/opentype-features-inspector) on the display font. It lists every defined feature tag, so you know whether `dlig`, `salt`, or a stylistic set is real before committing a token.
- Step 5Encode the rules as design tokens — Express each default as a token, e.g. `--font-features-body`, `--font-features-code`, `--font-features-display`, and apply them in the component layer. This keeps the convention in one place and makes drift reviewable in a diff.
- Step 6Document the rationale next to the tokens — Add a one-line comment per token (why code is off, why display has `dlig`) so the next contributor understands the intent. Pair with [font-face-generator](/font-tools/font-face-generator) so the `@font-face` and feature tokens live together.
Per-role ligature convention
A documented default for each typographic role. font-feature-settings inherits, so the override on code/pre cascades to nested elements automatically. Verify the display row against your real font before shipping dlig.
| Role / selector | font-feature-settings | Why |
|---|---|---|
:root / body | "liga" 1, "calt" 1, "kern" 1 | Explicit baseline — standard ligatures, contextual alternates, kerning all on (the browser default, made non-surprising) |
code, pre, kbd, samp | "liga" 0, "calt" 0 | 1:1 character-to-glyph mapping so fi in fix() never fuses and copy-paste is exact |
h1, h2 (display) | "liga" 1, "calt" 1, "dlig" 1 | Editorial polish — but only meaningful if the display font ships dlig glyphs (verify first) |
| Tabular figures (optional) | "tnum" 1, "liga" 0 | Numeric columns: monospaced figures + no fusing for aligned data tables |
| Reset to font default | normal | font-feature-settings: normal clears any inherited override and falls back to the font's own defaults |
Two ways to write the same intent
The high-level font-variant-ligatures alias vs the low-level font-feature-settings the Ligature Toggler emits. Pick one and stay consistent within the system.
| Intent | font-variant-ligatures (semantic) | font-feature-settings (low-level) |
|---|---|---|
| Body default | common-ligatures contextual | "liga" 1, "calt" 1 |
| Code off | no-common-ligatures no-contextual | "liga" 0, "calt" 0 |
| Headline with discretionary | common-ligatures discretionary-ligatures | "liga" 1, "dlig" 1 |
| Reset | normal | normal |
| Support (caniuse, approx.) | ~97% | ~99% |
Cookbook
Drop-in CSS for the common design-system roles, with the verify-first step for anything involving dlig. Adapt selectors to your token naming.
Body / root default (make the implicit explicit)
ExamplePinning the default on the root stops any component from inheriting a surprise and gives contributors a clear baseline to override against.
:root {
font-feature-settings: "liga" 1, "calt" 1, "kern" 1;
/* equivalent: font-variant-ligatures: common-ligatures contextual; */
}Code blocks: ligatures off everywhere they nest
ExampleBecause font-feature-settings inherits, one rule covers every token, string, and comment inside a code block.
code, pre, kbd, samp {
font-feature-settings: "liga" 0, "calt" 0;
}
/* Now: fix(offset) renders f-i-x, not f[fi]x */Headline dlig — but only after verifying
ExampleRun the display font through the Ligature Toggler first. If row 3 (dlig) is identical to row 2, drop dlig from the token; it's dead CSS on a font without discretionary glyphs.
/* Step 1: Ligature Toggler -> does row 3 differ from row 2? */
/* Step 2: if yes, ship this; if no, omit "dlig" */
h1, h2 {
font-feature-settings: "liga" 1, "calt" 1, "dlig" 1;
}Design tokens (CSS custom properties)
ExampleKeep the convention in one place so drift shows up in a diff, not in a screenshot review three sprints later.
:root {
--ff-body: "liga" 1, "calt" 1, "kern" 1;
--ff-code: "liga" 0, "calt" 0;
--ff-display: "liga" 1, "calt" 1, "dlig" 1;
}
body { font-feature-settings: var(--ff-body); }
pre { font-feature-settings: var(--ff-code); }
h1,h2 { font-feature-settings: var(--ff-display); }Tabular data: align numbers, no fusing
ExampleFor dashboards and financial tables, combine tabular figures with ligatures off so columns line up and nothing fuses.
.data-table td {
font-feature-settings: "tnum" 1, "liga" 0;
font-variant-numeric: tabular-nums;
}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.
A stray dlig 1 cascades into body cards
Inheritance trapfont-feature-settings inherits. Put "dlig" 1 on a wrapper and every descendant gets discretionary ligatures until something overrides it — so a card nested under a styled section can pick up headline features unintentionally. Pin the body default explicitly and scope dlig tightly (e.g. h1, h2 only).
font-feature-settings is all-or-nothing, not additive
GotchaSetting font-feature-settings: "dlig" 1 on an element does NOT merge with an inherited "liga" 1 — it replaces the whole value, which can switch liga off because the new declaration only lists dlig. Always restate every feature you want: "liga" 1, "calt" 1, "dlig" 1. The Ligature Toggler's snippets already do this.
dlig in the token but the font has no discretionary glyphs
Dead CSSMost text fonts ship no dlig. The rule is harmless (a no-op) but misleading — it implies the headline has discretionary ligatures when it does not. Verify in the Ligature Toggler (row 3 vs row 2) and remove dlig from the token if it does nothing.
Coding-ligature font (Fira Code) used for code blocks with liga 0
ExpectedIf your design system uses Fira Code or JetBrains Mono, "liga" 0, "calt" 0 disables the =>/!= symbol ligatures — which some teams want (exact fidelity) and some do not (readability). Make this an explicit, documented decision rather than an accidental side effect of the global code rule.
Disabling calt breaks a connecting display script
Breaks renderingIf a brand uses a script/handwriting display face, turning calt off (as the generic code rule does) breaks the letter joins. Never apply the code rule to script display elements; scope the off-rule to monospace selectors only.
Tailwind / utility CSS overriding the token
Specificity clashA utility class that sets font-feature-settings will win by source order or specificity over your token. Decide whether the convention lives in base layer (@layer base) or component layer, and keep utilities out of the feature-settings business unless deliberately overriding.
Required ligatures still render in code blocks
By designEven with "liga" 0, "calt" 0 on code, required ligatures (rlig) for complex scripts stay on — the browser ignores attempts to disable them. This is correct: a code block containing Arabic should still shape that Arabic. Latin code is unaffected.
Variable display font with axis-dependent features
Verify per instanceSome variable fonts expose different alternates at different optical-size or weight instances. Verify the headline feature at the actual axis values you ship; freeze the instance with variable-font-freezer if you want a single deterministic file, then re-check in the Toggler.
Frequently asked questions
Should I use font-variant-ligatures or font-feature-settings in tokens?
Either works; pick one and be consistent. font-variant-ligatures (common-ligatures, no-common-ligatures, discretionary-ligatures) is semantically clearer (~97% support). font-feature-settings is lower-level (~99% support) and is what the Ligature Toggler outputs. Mixing both in the same system invites confusion, so standardise.
Why does my code block still fuse fi even though I set liga 0 on body?
Because something between body and the code element re-declared font-feature-settings with liga back on (and font-feature-settings replaces, not merges). Audit the cascade, set the off-rule directly on code, pre, and remember every declaration must restate all the features it wants on.
Is font-feature-settings inherited?
Yes. Children inherit the parent's value unless they set their own. That's why disabling ligatures on code, pre cleanly covers nested tokens — but also why a stray rule on a wrapper cascades everywhere. Pin the body default and scope overrides tightly.
What does font-feature-settings: normal do?
It clears any inherited or set value and falls back to the font's own default feature behaviour. Useful as an escape hatch on an element that should ignore an inherited override and just render the font's defaults.
How do I know if my display font has discretionary ligatures worth enabling?
Drop it into the Ligature Toggler and compare row 3 (discretionary on) with row 2 (standard). If they look different, the font has dlig glyphs. For a definitive list of every feature the font defines, run opentype-features-inspector.
Should the convention live in the base layer or component layer?
Put the role defaults (body/code) in the base layer so they apply globally, and component-specific opt-ins (headline dlig) in the component layer. This keeps specificity predictable and stops utility classes from accidentally clobbering the baseline.
Do coding ligatures belong in a design system?
Only as an explicit decision. Fonts like Fira Code turn =>/!= into single glyphs; document whether your system wants that (readability) or disables it (exact character fidelity). The generic code { liga 0 } rule turns them off, so be deliberate.
Will the Ligature Toggler generate the design-system CSS for me?
It generates a preview plus two CSS snippets (disable site-wide, enable dlig for headlines). Those are the building blocks; you assemble them into your token structure. For broader font CSS (variables, scales), pair with css-variable-generator-font and typography-scale-builder.
How do tabular figures interact with ligatures?
They're independent features. For data tables, combine tnum (or font-variant-numeric: tabular-nums) with liga 0 so numbers align in columns and nothing fuses. Set both on the table cell selector.
Does any of this modify the font binary?
No. Everything here is CSS. The Ligature Toggler outputs CSS and an HTML preview; it never rewrites the font's GSUB table. Your .woff2 files ship unchanged — the convention lives entirely in your stylesheet/tokens.
How do I stop drift over time?
Centralise the rules as tokens, document the rationale inline, and review changes in diffs. For CI enforcement (e.g. fail the build if a new font lacks expected features), see the build-time generation guide.
Is the font uploaded when I preview in the Ligature Toggler?
No — it processes in your browser (or your machine via the runner on paid tiers) and shows a 0 bytes uploaded badge. You can verify unreleased brand display fonts safely before committing the design-system token.
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.