The font-variant Property for Typographic Control: Small Caps, Numerals, and Ligatures

Control small caps, tabular numerals, and ligatures with the font-variant CSS property—and know the fallback when the font lacks the OpenType feature.

Typographic control in CSS is usually about choosing a font and a size. The font-variant property is where a page stops looking like a document and starts looking like a designed product. It and its longhand sub-properties give you access to OpenType features that ship inside the font file itself: small caps for headings that do not shout, tabular numerals for columns of prices, ligatures that make letter pairs flow into a single glyph. The shorthand resets everything. The longhands let you compose. This guide covers the real syntax, the fallbacks when a font lacks a feature, and the distinction between font-variant and the older font-feature-settings, which too many tutorials conflate.

The font-variant Property for Typographic Control: Small Caps, Numerals, and Ligatures

The font-variant CSS property is not a single switch. The shorthand font-variant: small-caps is the CSS 2.1 legacy. Setting it resets font-variant-numeric, font-variant-ligatures, font-variant-alternates, and font-variant-east-asian to their initial value normal. That reset is the most common failure in production CSS. A developer writes font-variant: small-caps on a table header to get the title case effect, and the tabular-nums they set on the table body silently vanish, because the shorthand reinitializes every longhand it does not mention. The computed value of the shorthand is as specified, per the spec, and the cascade treats each longhand independently unless you use the shorthand. For small caps alone, use the longhand font-variant-caps: small-caps. It leaves the numeric and ligature settings untouched.

font-variant-caps small-caps and the Fake Small Caps Fallback

font-variant-caps: small-caps requests true small caps from the font. The typeface designer drew a separate set of capital letterforms at a reduced x-height, with thicker stems to match the lowercase weight. A browser that lacks the feature is allowed to synthesize them, scaling down the full capitals. That produces spindly strokes and uneven colour. The control surface for that synthesis is font-synthesis: small-caps. Set font-synthesis: none when you want the browser to leave the text unstyled rather than fake it.

Guarding Against Synthesised Small Caps

The accepted fallback for font-variant-caps is normal. That is what you get when the font does not ship the small-caps OpenType feature and the browser decides not to synthesize. The feature query guard for font-variant-caps is @supports (font-variant-caps: small-caps) { … }. Inside that block you can safely set the longhand. Outside it, you decide whether to accept the synthesised version or drop the effect entirely.

/* True small caps with a synthesis guard */
@supports (font-variant-caps: small-caps) {
  .pull-quote {
    font-variant-caps: small-caps;
    font-synthesis: none;
  }
}

@supports not (font-variant-caps: small-caps) {
  .pull-quote {
    font-variant-caps: normal;
  }
}

The @supports test checks the property:value pair, not the font. A browser that supports the CSS property will pass the guard even if the loaded font has no small-caps glyphs, so the font-synthesis: none inside the guard is what protects you from the fake. The @supports not block gives you a clean normal fallback. The loading cost of a font that includes small caps is real: the OpenType feature table adds glyphs and kerning data. If the weight of the file matters more than the effect, subset the woff2 to keep only the small-caps glyphs you will use, a technique that requires a font-subsetting tool at build time.

font-variant-numeric tabular lining and the Data Table Requirement

font-variant-numeric: tabular-nums lining-nums is the pair that makes tables legible. Tabular figures give every digit the same advance width, so columns of numbers align vertically instead of jiggling left and right as the digits change. Lining figures sit on the baseline and share the height of capitals, which suits data tables and forms. The longhand accepts a list: normal | [ <numeric-figure-values> || <numeric-spacing-values> || <numeric-fraction-values> || ordinal || slashed-zero ]. The figure values are lining-nums and oldstyle-nums; the spacing values are proportional-nums and tabular-nums. To get tabular lining, write font-variant-numeric: tabular-nums lining-nums. The order does not matter because the syntax uses the double-bar separator. The shorthand font-variant: tabular-nums would reset the ligatures and the caps, so the longhand is the safe route.

Building a Fallback for Numeric Settings

The accepted fallback is normal, which is what you get when the font has no OpenType feature for tabular figures. The feature query guard for font-variant-numeric is @supports (font-variant-numeric: oldstyle-nums) { … }. You can use the same guard for tabular-nums because the property support is the same. Inside the guard, set the longhand; outside, accept the proportional figures and add a min-width to the table cells to reduce the jitter.

/* Tabular lining numerals for a data table */
@supports (font-variant-numeric: tabular-nums) {
  .price-table td {
    font-variant-numeric: tabular-nums lining-nums;
  }
}

@supports not (font-variant-numeric: tabular-nums) {
  .price-table td {
    font-variant-numeric: normal;
    min-width: 3ch;
  }
}

The oldstyle-nums variant, where lowercase digits descend below the baseline, suits running prose and prices inside sentences. It is the wrong choice for a column of figures. The slashed-zero variant distinguishes the zero from the capital O, which matters in code listings and part numbers. The ordinal variant handles superscripts in words like 1st and 2nd; without it, the browser may render a full-size 1 followed by a superscript st, which is typographically wrong. The font-variant shorthand with the value none resets all the longhands. Do not use it on a table that also needs small caps elsewhere.

font-variant-ligatures discretionary and the Letter-Pair Decision

font-variant-ligatures: discretionary-ligatures turns on the decorative ligatures the font designer drew for pairs like fi, fl, and occasionally ct or st. The base setting is font-variant-ligatures: common-ligatures, which enables the standard ligatures that are on by default in most fonts. The discretionary-ligatures value adds the ones that are not safe for every context; they can change the readability of a word if used carelessly. The longhand syntax is font-variant-ligatures: normal | none | [ <common-lig-values> || <discretionary-lig-values> || <historical-lig-values> || <contextual-alt-values> ]. The none value disables all ligatures, including the common ones. That is what you want for code where fi in a string must stay two separate glyphs.

Why the Cascade Favours the Longhand

The accepted fallback is normal, which means the browser uses the font’s default ligature settings. The feature query guard for font-variant-ligatures is @supports (font-variant-ligatures: common-ligatures) { … }. You can test the discretionary value the same way. The difference between font-variant-ligatures and font-feature-settings: "dlig" is the cascade. font-feature-settings does not participate in the shorthand cascade; it is a direct pass-through of OpenType feature tags, and it does not merge with other settings. If you set font-feature-settings: "dlig" on a paragraph and font-feature-settings: "lnum" on a table inside it, the table loses the discretionary ligatures because the second declaration replaces the first. font-variant-ligatures merges with font-variant-numeric in the cascade because they are separate longhands of the same shorthand.

/* Discretionary ligatures with common ligatures as the base */
@supports (font-variant-ligatures: discretionary-ligatures) {
  .display-type {
    font-variant-ligatures: common-ligatures discretionary-ligatures;
  }
}

@supports not (font-variant-ligatures: discretionary-ligatures) {
  .display-type {
    font-variant-ligatures: common-ligatures;
  }
}

The loading cost question applies here too: a font file that includes the discretionary ligature glyphs is larger than the same file without them, because each ligature is a separate glyph outline plus the substitution rules in the OpenType layout table. If you only use the ligatures on a single heading, subset the font to keep only those glyphs and the rules that reference them. The build step must keep the feature tag in the subset.

font-variant-alternates stylistic sets and the Variable Font Axis

font-variant-alternates gives you access to the alternate glyphs a font designer stored under stylistic set names, typically ss01 through ss20. The syntax is font-variant-alternates: stylistic(ss01). You can stack multiple sets: styleset(ss01 ss02). The historical-forms value is part of the same longhand, and the character-variant, swash, ornaments, and annotation values cover the other alternate glyph categories.

Checking Support for Stylistic Sets

The Baseline status for font-variant-alternates is Newly available since September 2023. Older browsers may not parse it. The feature query guard is @supports (font-variant-alternates: historical-forms) { … }. Test the specific value you intend to use inside the guard.

In variable fonts, the stylistic set often maps to a discrete axis rather than a continuous range. The font-variation-settings property controls the continuous axes like weight and width, but a stylistic set is a named instance, not a continuous value. A variable font that ships a stylistic set axis exposes it as a boolean or an enumerated value. You cannot transition between ss01 and ss02 with a CSS transition because the axis is not interpolatable. The fallback is normal, which uses the default glyph for each character. The failure case is a font that does not have the requested set: the browser silently ignores the value and renders the default forms. There is no error, no console message, and no way to detect it from CSS alone. Inspect the font’s feature list at build time with a font inspection tool, or at runtime by rendering a known test string and comparing the glyph widths.

font-variant-east-asian and the Ruby Annotation Control

font-variant-east-asian is the longhand for East Asian typographic conventions: variant forms like jp78, jp83, and jp90, simplified and traditional forms, and the proportional and full-width figures. The ruby value controls the ruby annotation glyphs that sit above or beside the base characters in Japanese and Chinese text. The Baseline status is Widely available since July 2016, so the property itself is safe to use. The font must include the specific feature for the value to have any effect. The accepted fallback is normal, which uses the font’s default forms. The feature query guard is @supports (font-variant-east-asian: ruby) { … }.

The practical difference between font-variant-east-asian and font-feature-settings: "ruby" is the same cascade problem as the other features: font-feature-settings does not merge, and any use of it on a parent overrides the child’s settings. The font-variant-east-asian longhand participates in the font-variant shorthand, so setting font-variant: normal on a parent resets it. For a page that mixes Japanese text with a Latin data table, you want the table’s tabular-nums to survive the Japanese paragraph’s ruby setting. Only the longhand composition makes that possible.

font-feature-settings vs font-variant and the Cascade Difference

The choice between font-feature-settings and font-variant separates a working typographic system from a fragile one. font-feature-settings was the only way to access OpenType features before the font-variant longhands shipped. It still has a role for features that have no font-variant equivalent, like contextual alternates not exposed through any sub-property. The problem is that font-feature-settings uses the raw OpenType tag syntax, requires a string like "lnum", and does not cascade. Setting font-feature-settings: "lnum" on a table and font-feature-settings: "dlig" on a paragraph inside it results in the paragraph losing the lnum because the second declaration replaces the first. The font-variant longhands merge: font-variant-caps on a parent and font-variant-numeric on a child combine cleanly. The child inherits the caps unless it sets its own font-variant-caps.

When to Use the Raw Tag Interface

The feature query guards for font-variant-ligatures, font-variant-numeric, and font-variant-caps all target the property:value pair. A browser that passes the guard supports the cascade merging. A browser that does not pass the guard falls back to font-feature-settings. In that fallback you must manually re-declare every feature you need on every element that needs it. The loading cost of a font that includes these OpenType features versus one that does not is measurable in the woff2 file size: the feature tables add the glyph variations and the substitution rules. A display face with twenty stylistic sets can double the file size. The font-display fallback stack matters here. If you load the feature-rich font with font-display: swap, the text renders in a fallback font first, and the swap happens when the rich font arrives. You need a fallback stack that includes a font with the same metrics, or the layout shifts when the swap occurs.

@supports font-variant-caps and the Feature Detection Strategy

The @supports (font-variant-caps: small-caps) guard is what you write when you want true small caps and you are willing to accept normal as the fallback. The test does not check the font; it checks the CSS parser. A browser that supports the property will pass the test regardless of the font’s glyph inventory. The real detection of a font’s features happens at runtime, and CSS has no mechanism for that. You can approximate it by rendering a test string and measuring the glyph width, but that requires JavaScript and a canvas. For the CSS-only approach, the @supports guard plus font-synthesis: none gives you the safest combination: the browser uses the font’s small caps if they exist, and does nothing if they do not.

Handling the Missing-Feature Case

The failure case is a browser that passes the @supports test but loads a font without the small-caps feature. The font-synthesis: none inside the guard prevents the fake, and the text renders as normal lowercase and capitals. If you want the visual effect regardless of the font’s features, set font-synthesis: small-caps. The browser scales down the capitals. The scaled-down version is thinner at the same optical size, which is noticeable in a paragraph of continuous text. The accepted fallback for font-variant-caps is normal. That is what you should default to outside the @supports guard.

The Shorthand and Its Reset Trap

The font-variant shorthand is a convenience that carries a trap. The syntax from the spec is font-variant: normal | none | [ <common-lig-values> || <discretionary-lig-values> || <historical-lig-values> || <contextual-alt-values> || stylistic(<feature-value-name>) || historical-forms || styleset(<feature-value-name>#) || character-variant(<feature-value-name>#) || swash(<feature-value-name>) || ornaments(<feature-value-name>) || annotation(<feature-value-name>) || [ small-caps | all-small-caps | petite-caps | all-petite-caps | unicase | titling-caps ] || <numeric-figure-values> || <numeric-spacing-values> || <numeric-fraction-values> || ordinal || slashed-zero || <east-asian-variant-values> || <east-asian-width-values> || ruby ]. The value none is the nuclear option: it resets all the longhands to normal, which disables every ligature, every alternate, and every numeric setting. The value normal is the initial value, and it lets each font use its default features.

If you set font-variant: small-caps on a heading that sits inside a table with font-variant-numeric: tabular-nums, the shorthand resets the numeric setting to normal. The table’s numbers lose their alignment. The fix is to use the longhand font-variant-caps: small-caps, or to re-declare the numeric setting after the shorthand. The cascade order determines the result: a later font-variant-numeric declaration overrides the earlier shorthand’s reset. The specificity of a class selector does not save you from the shorthand’s reset; the reset is part of the computed value.

The Fallback Stack and the Loading Cost

The font-display fallback stack for a page that uses font-variant features must include a font that has the same metrics, or the text will shift when the webfont loads. The font-display: swap value means the fallback renders first, and the swap happens when the real font arrives. If the fallback does not have the same advance widths, the tabular-nums alignment you set will be off during the flash of unstyled text. The loading cost of a font that includes these OpenType features versus one that does not is the difference in woff2 file size. A text face with small caps, tabular figures, oldstyle figures, and a standard set of ligatures is larger than the same face without those features. A display face with discretionary ligatures and stylistic sets can be larger still. That cost is paid once per page load. The decision to use the feature-rich font is a trade between typographic quality and performance.

Subsetting to Control the Cost

The fallback stack that ignores the feature problem is worse: if the fallback font does not have the tabular figures, the numbers will align differently during the swap. The practical approach is to subset the webfont to include only the features you use. A woff2 subset with tabular-nums and small caps but without the discretionary ligatures is smaller than the full font, and the font-display: swap will be quick. The @supports guard cannot help with the loading cost; it only controls the CSS property. The font loading is separate, and the performance budget is your own.

When the Font Lacks the Feature: Debugging at 1am

The failure case for font-variant is a font that does not have the feature. There is no error, no console message, and no visual placeholder. The text renders with the default forms. The only way to know is to compare the rendered output against the CSS you wrote. At 1am, when you are debugging a table that will not align, the first thing to check is not the CSS but the font itself. Open the font in an inspection tool, or render a test string with known tabular digits and measure the widths. If the widths vary, the font lacks the tabular figures.

Four Common Failures and Their Fixes

The second failure is the shorthand reset. You set font-variant: small-caps on a parent and the tabular-nums on the child disappears. Check the computed style in the devtools and see what font-variant-numeric resolves to. It will be normal. The third failure is the fake small caps. The font has no small-caps glyphs, the browser synthesizes them, and the text looks thin and uneven. The fix is font-synthesis: none, which you should have set from the start.

The fourth failure is the font-feature-settings override. You or a library set font-feature-settings: "tnum" on a container, and a child with font-variant-numeric: tabular-nums does not override it because font-feature-settings does not participate in the font-variant cascade. The child needs its own font-feature-settings: "tnum", or you remove the font-feature-settings from the container entirely. The rule is straightforward: use font-variant longhands for everything that has a longhand, and reserve font-feature-settings for the features that have no other CSS interface.

The Accessibility of All-Caps and Screen Readers

The accessibility of all-caps is a separate concern from the visual effect. If you use font-variant-caps: all-small-caps to render a heading, the text in the accessibility tree is still the original lowercase or mixed-case string, because font-variant does not change the text content; it changes the glyphs. A screen reader will read the string as written, which is correct. If you instead write the heading in uppercase in the HTML and use font-variant-caps: small-caps, the screen reader will spell out the acronym, which is wrong. Keep the source text in the natural case and let the CSS transform the glyphs. The all-caps value in the accessibility tree is not a problem; the problem is when the source text is in all caps to achieve the visual effect, and the screen reader expands the letters into words. The font-variant-caps property does not affect the accessibility tree. It is the safe choice.

The failure case is a page that uses text-transform: uppercase and then the screen reader reads the acronym. Remove the text-transform and use font-variant-caps: small-caps on the natural-case source. The visual difference is subtle, and the accessibility win is real. The @supports guard for font-variant-caps does not change the accessibility behaviour; it only controls the visual rendering.

The Real Interop Gap and the Practical Baseline

The real-world interop gap for font-variant is not the property support; it is the font feature availability. The CSS properties are Baseline, widely available since July 2016 for the shorthand, caps, numeric, ligatures, and east-asian. font-variant-alternates is newly available since September 2023, and older browsers will ignore it silently. The gap is that a browser can support the CSS and still render nothing different because the font lacks the feature. The @supports guard tells you the browser can parse the value; it does not tell you the font has the glyphs. Design with the fallback in mind: the page must look acceptable with the default forms, and the font-variant features are an enhancement.

The font-feature-settings versus font-variant distinction is the same practical gap. font-feature-settings is supported everywhere, but it does not merge, and it is a string-based interface that is easy to get wrong. The font-variant longhands are the correct interface, and the @supports guard is the way to use them safely. The loading cost is the final consideration: a font with the features is larger, and the fallback stack must handle the swap. The page that gets this right treats font-variant as a progressive enhancement, not a requirement.

A Practical Comparison Table

Feature font-variant longhand font-feature-settings tag Cascade behavior Baseline
Small caps font-variant-caps: small-caps “smcp” Merges with other longhands Widely available since July 2016
Tabular figures font-variant-numeric: tabular-nums “tnum” Merges with other longhands Widely available since July 2016
Oldstyle figures font-variant-numeric: oldstyle-nums “onum” Merges with other longhands Widely available since July 2016
Slashed zero font-variant-numeric: slashed-zero “zero” Merges with other longhands Widely available since July 2016
Common ligatures font-variant-ligatures: common-ligatures “liga” Merges with other longhands Widely available since July 2016
Discretionary ligatures font-variant-ligatures: discretionary-ligatures “dlig” Merges with other longhands Widely available since July 2016
Stylistic sets font-variant-alternates: stylistic(ss01) “ss01” Merges with other longhands Newly available since September 2023
Ruby annotation font-variant-east-asian: ruby “ruby” Merges with other longhands Widely available since July 2016

When the Shorthand Resets Everything

The shorthand font-variant: small-caps is a two-value legacy that resets the other longhands. Use the longhand for each effect you need. The @supports guard is the detection strategy for each longhand. The fallback is normal for every one of them. The loading cost is the file size of the feature-rich font, which you can measure and decide against. The font-feature-settings versus font-variant decision is a cascade problem, not a support problem. The accessibility of all-caps is a source-text problem, not a CSS problem. The interop gap is the font’s glyph inventory, not the browser’s parser.

The Complete Runnable Sample

A page that uses tabular numerals for a data table, small caps for the table caption, and discretionary ligatures for the display heading needs three separate @supports guards and a fallback stack that does not shift. The sample below is complete and runnable in any modern browser.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Font Variant Sample</title>
  <style>
    /* Fallback stack: a system font with tabular figures first */
    body {
      font-family: "Avenir Next", "Segoe UI", "Helvetica Neue", system-ui, sans-serif;
      font-display: swap;
    }

    @supports (font-variant-numeric: tabular-nums) {
      .price-table td {
        font-variant-numeric: tabular-nums lining-nums;
      }
    }

    @supports not (font-variant-numeric: tabular-nums) {
      .price-table td {
        font-variant-numeric: normal;
        min-width: 3ch;
      }
    }

    @supports (font-variant-caps: small-caps) {
      .table-caption {
        font-variant-caps: small-caps;
        font-synthesis: none;
      }
    }

    @supports not (font-variant-caps: small-caps) {
      .table-caption {
        font-variant-caps: normal;
      }
    }

    @supports (font-variant-ligatures: discretionary-ligatures) {
      .display-heading {
        font-variant-ligatures: common-ligatures discretionary-ligatures;
      }
    }

    @supports not (font-variant-ligatures: discretionary-ligatures) {
      .display-heading {
        font-variant-ligatures: common-ligatures;
      }
    }
  </style>
</head>
<body>
  <h1 class="display-heading">The Office of the Night</h1>
  <p class="table-caption">Prices for the midnight service</p>
  <table class="price-table">
    <tr><td>12.50</td><td>24.00</td><td>36.75</td></tr>
    <tr><td>1.25</td><td>2.00</td><td>3.75</td></tr>
  </table>
</body>
</html>

The font-display: swap on the body is a hint to the browser that the fallback font may be used until the webfont loads. The fallback stack includes Avenir Next and Segoe UI, which both have tabular figures, so the alignment holds during the swap. The @supports guards are independent, and each one has a not block that provides the fallback. The font-synthesis: none inside the small-caps guard prevents the fake. The whole sample is the pattern you want to copy.

The Meta: What This Page Says That Others Do Not

A text face with small caps, tabular figures, and a standard ligature set is larger in woff2 than the same face without those features, so you should subset the webfont to keep only the glyphs and feature tables you actually use, and pair that subset with a fallback font that has the same tabular metrics to prevent layout shift during the font-display swap.