Controlling the opsz Axis in Variable Fonts: Auto vs Manual Optical Sizing

This deep-dive sits inside the Optical Sizing & Variable Axes guide, part of the broader Typography Fundamentals & System Architecture blueprint. Here we resolve one narrow, high-stakes detail: how to drive the optical-size (opsz) axis of a variable font correctly, and why a single line of font-variation-settings can silently strip the automatic behaviour you thought you had.

Problem Statement

A variable font that ships an opsz axis is designed to render differently at different sizes: thicker stems, more open spacing, and larger apertures at small sizes for legibility; finer details and tighter spacing at display sizes. The browser can wire this up automatically through font-optical-sizing: auto, mapping the rendered font-size (in CSS pixels) onto the opsz axis value for you. The failure mode is subtle: the moment you add any font-variation-settings declaration — to tweak wght, slnt, or anything else — the browser drops the automatic opsz mapping unless you explicitly restate opsz inside that same declaration. The result is body text rendered with a display-weight optical size (spindly, hard to read at 14px) with no error, no warning, and no visual cue in your source CSS that anything changed.

Prerequisites

Before any of this matters, three things must be true:

  • You are serving a variable font that actually contains an opsz axis. Many variable fonts only ship wght and wdth. Confirm with fonttoolsfonttools varLib.instancer --help lists axes, or inspect the fvar table directly. Fonts like Roboto Flex, Source Serif 4, Newsreader, and Bricolage Grotesque expose opsz.
  • The font is loaded through an @font-face rule (or a self-hosted bundle) — review your variable font loading technique so the file is actually delivered before you fight the axis.
  • You understand that font-optical-sizing: auto is the initial value in CSS. So in the absence of any font-variation-settings, optical sizing is already on for fonts that support it. You only ever need to act to (a) turn it off, (b) set a manual value, or (c) restore it after a font-variation-settings rule clobbered it.
How opsz is resolved: auto mapping vs manual override vs the clobber gotcha Three branches show font-size feeding the opsz axis under auto, a manual opsz value overriding it, and font-variation-settings dropping auto unless opsz is restated. font-size e.g. 14px / 48px optical-sizing: auto size drives opsz settings: 'opsz' 48 manual pin settings: 'wght' 600 no opsz = auto dropped rendered opsz glyph shape
The dashed branch is the gotcha: adding font-variation-settings without restating opsz freezes the axis at its default.

Implementation

The correct setup has two distinct intents that must not be mixed: let the browser map size to opsz, or pin opsz yourself. Here is the canonical, annotated pattern covering both.

Correct auto optical sizing plus a correct manual override

/* 1. Register the variable font and DECLARE its opsz range.
      Without the opsz entry in font-face, the browser will not
      animate the axis even when optical sizing is on. */
@font-face {
  font-family: "Roboto Flex";
  src: url("/fonts/roboto-flex.woff2") format("woff2-variations");
  font-weight: 100 1000;
  font-stretch: 25% 151%;
  font-display: swap;
  /* opsz axis exposed to CSS via the supported range */
  font-optical-sizing: auto; /* harmless here; real control is below */
}

/* 2. AUTO: body copy. The browser ties opsz to the px font-size.
      This is the initial value, written explicitly for clarity. */
.prose {
  font-family: "Roboto Flex", system-ui, sans-serif;
  font-size: 1rem;             /* ~16px -> small optical size */
  font-optical-sizing: auto;   /* size now drives the opsz axis */
}

/* 3. MANUAL: a display headline pinned to a specific optical size,
      independent of its rendered px size. Note opsz is RESTATED
      alongside wght so the wght tweak does not strip optical sizing. */
.display {
  font-family: "Roboto Flex", system-ui, sans-serif;
  font-size: clamp(2rem, 5vw, 4rem);
  /* Setting any axis here switches opsz from auto to manual.
     We pin opsz to 48 (a display value) AND set weight. */
  font-variation-settings: "opsz" 48, "wght" 340;
}

Each critical line earns its place. Line 2's font-optical-sizing: auto on .prose is technically redundant (it is the default) but documents intent — anyone editing this rule later sees that optical sizing is deliberate, not accidental. The @font-face block must reference a variable file (format("woff2-variations") or plain format("woff2") in modern browsers) for the axis to exist at all.

The load-bearing detail is in .display. font-variation-settings is the lowest-level axis control, and using it disables font-optical-sizing: auto for that element. If you had written only font-variation-settings: "wght" 340, the opsz axis would freeze at the font's default opsz (often the lowest value, e.g. opsz 8), giving your 64px headline the chunky, small-text optical treatment. By explicitly listing "opsz" 48, you pin the axis where a display size belongs. The rule of thumb: the moment you touch font-variation-settings on an element that uses an opsz font, you own the opsz value — restate it or lose it.

Defensive Variant: Restore Auto, with a Static Fallback

Sometimes you need a low-level wght tweak but want optical sizing to stay automatic. There is no font-variation-settings: "opsz" auto keyword — the value must be a number. The defensive pattern is to avoid font-variation-settings for weight (use the high-level font-weight property, which does not clobber optical sizing) and reserve font-variation-settings only for axes with no high-level property. Pair it with an @supports guard so non-variable fallbacks render sanely.

Keep auto optical sizing while still varying weight, with a static safety net

.article-body {
  font-family: "Roboto Flex", Georgia, serif;
  font-weight: 380;            /* high-level prop: does NOT disable auto opsz */
  font-optical-sizing: auto;   /* survives, because we never wrote
                                  font-variation-settings here */
}

/* Only reach for font-variation-settings when an axis has no
   high-level equivalent (e.g. grade GRAD). Restate opsz to keep
   optical sizing alive for that element. */
@supports (font-variation-settings: "GRAD" 0) {
  .article-body.is-dense {
    /* pick a mid optical size explicitly; auto is unavailable
       once any axis is set here */
    font-variation-settings: "GRAD" 88, "opsz" 14;
  }
}

/* Static fallback: if the variable font fails to load and Georgia
   takes over, none of the axis logic applies and text stays legible. */
@supports not (font-variation-settings: "wght" 400) {
  .article-body { font-weight: 400; }
}

The key defensive move is preferring font-weight: 380 over font-variation-settings: "wght" 380. The former adjusts the wght axis through the high-level cascade without disabling automatic optical sizing; the latter would disable it. This lets you ship variable weight and automatic opsz simultaneously — the combination most teams actually want. Only when you need an axis with no CSS shorthand (such as grade, GRAD) do you drop to font-variation-settings, and there you dutifully restate opsz.

Verification

Confirm the axis is doing what you intend with three checks:

  1. DevTools computed value. Inspect the element, open the Computed pane, and read font-optical-sizing and font-variation-settings. In Chromium, the Styles pane also shows a font axis editor for variable fonts — the opsz slider position reflects the resolved value. If you see font-optical-sizing: auto but a font-variation-settings line without opsz, the axis is frozen at default.
  2. Visual comparison at extremes. Render the same string at 12px and 72px side by side. With auto working, the small instance shows heavier stems and more open counters; the large instance shows refined, tighter forms. If both look identical in proportion, optical sizing is not engaging.
  3. Scripted readback. In the console, getComputedStyle($0).fontVariationSettings returns the resolved axis string. For auto-driven opsz this property is often "normal" while the visual rendering still varies — so trust the visual test over the string when auto is in play.

Common Pitfalls

  • Setting font-variation-settings: "wght" N and losing optical sizing. The most common bug. Any font-variation-settings value disables font-optical-sizing: auto unless opsz is restated. Use font-weight for weight instead.
  • Assuming a font has an opsz axis when it ships only wght/wdth. No axis means no optical sizing, no matter what you declare. Verify the fvar table with fonttools first.
  • Writing font-variation-settings: "opsz" auto. Invalid — the value must be numeric. To get automatic behaviour, omit font-variation-settings and rely on font-optical-sizing: auto.
  • Pinning opsz to a tiny default on display headlines. Forgetting to set opsz on a large headline leaves it at the font's lowest optical size, producing clumsy, over-inked letterforms at scale.
  • Mismatched opsz between the web font and a fallback stack. The fallback is static and cannot optically size, so the swap can shift apparent weight; account for this when tuning metric overrides.

FAQ

Does adding font-variation-settings really turn off automatic optical sizing?

Yes. Per the CSS Fonts specification, font-optical-sizing has no effect when font-variation-settings sets the opsz axis, and any font-variation-settings declaration takes precedence for the axes it lists. If your declaration omits opsz, the axis is left at the font's default rather than tracking size. The fix is to either add "opsz" N to the same declaration or avoid font-variation-settings and use high-level properties such as font-weight.

What opsz value should I use for body text versus headlines?

There is no universal number — it depends on the font's design range, which you read from fvar. As a heuristic with auto on, the browser maps the px font-size onto the axis, so 16px body resolves to a low opsz and a 48px headline to a high one. If pinning manually, match the numeric opsz to your intended display size in points (e.g. "opsz" 12 for captions, "opsz" 48 for hero text).

Is font-optical-sizing supported widely enough to rely on?

Yes. font-optical-sizing: auto ships in Chrome 79+, Safari 13.1+, and Firefox 62+, and is the initial value. For fonts without an opsz axis it is simply a no-op, so it is safe to declare unconditionally.

Related