Optical Sizing & Variable Axes Implementation Pipeline
A single variable font file can carry every weight, width, and optical-size instance a design system needs, but if you wire it up wrong it renders body copy with display-grade thin strokes that vanish at small sizes, or it leaves the opsz axis pinned so headings look muddy. This guide is part of the Typography Fundamentals & System Architecture blueprint, and it covers how to register variable axes in @font-face, let the browser drive optical sizing automatically, override it deliberately, and ship the result without dragging axes you never use into every page load.
Problem Framing: Diagnosing a Mis-Sized Axis
Start with a diagnostic, not a rewrite. Open Chrome DevTools, select a paragraph of body text, and in Elements → Computed read the font-variation-settings and font-optical-sizing values, then check the Rendered Fonts line at the bottom of the Computed pane to confirm the variable face is actually resolving rather than a static fallback. Two failure signatures show up here. The first is flat optical sizing: every text size — 12px captions, 16px body, 48px headings — reports the same opsz value, which means the axis is pinned and the type designer's small-size legibility work is being ignored. The second is the inverted axis: small text rendering with high stroke contrast (hairline thins that disappear on a 1x display) because a display opsz value leaked onto body copy.
The metric at stake is twofold. Legibility failures at small sizes degrade reading comfort and, on low-DPI screens, can make thin strokes literally invisible — an accessibility regression you will not catch in a Lighthouse score but will catch in user testing. The performance side is Cumulative Layout Shift (CLS): when the variable font swaps in and its optical instance has a different x-height or advance width than the fallback, the line box reflows and scores against your CLS < 0.1 budget. Open the Performance panel, record a load on a throttled profile, and watch the Layout Shift track — any shift timed to the font swap is the metric you are trying to drive toward zero. The fix is rarely "remove the variable font"; it is "let opsz track the computed size and match the fallback metrics," which is what the rest of this guide builds.
The reason opsz matters more than designers expect is that it is the one axis the browser will adjust for you without any JavaScript. A type family with a true optical-size axis was drawn at multiple sizes by its designer: at caption sizes it opens counters, thickens thin strokes, and loosens spacing so glyphs survive at 11–12px; at display sizes it sharpens contrast and tightens spacing so large headings look crisp rather than clumsy. Pinning the axis throws all of that away and uses one frozen instance everywhere. Allowing it to interpolate gives you size-appropriate rendering across your entire type scale for the cost of a single CSS declaration.
It helps to be precise about the axis vocabulary, because the registered axes behave differently and conflating them causes most of the bugs in this area. There are five registered axes with four-character tags: wght (weight, mapped by the high-level font-weight property), wdth (width, mapped by font-stretch), opsz (optical size, mapped by font-optical-sizing), slnt (slant, a continuous oblique angle), and ital (italic, a 0/1 toggle to a true italic design). Each registered axis has a high-level CSS property that composes cleanly with the cascade; reach for the low-level font-variation-settings only for custom (foundry-specific) axes or when you genuinely need a numeric value the high-level property cannot express. opsz is special among them because it is the only axis whose default behavior is automatic: with font-optical-sizing: auto the browser reads the computed pixel size and selects the matching optical instance frame by frame, so a fluid heading that grows from 24px to 48px walks the opsz axis continuously with no extra code.
Baseline Configuration: The Minimum Correct Setup
Before any optimization, get the registration and the global toggle right. Two things must be true: the @font-face block must declare the variable ranges so the font loader knows it is dealing with a variable font, and font-optical-sizing: auto must be set so the browser drives opsz from the computed font size.
Minimum variable-font registration with automatic optical sizing
@font-face {
font-family: "InterVariable";
src: url("/fonts/InterVariable.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
:root {
font-family: "InterVariable", system-ui, sans-serif;
font-optical-sizing: auto;
}
Read it carefully. The font-weight: 100 900 range (not a single value) is what tells the engine the wght axis is continuous; declaring a single weight collapses it to one instance. format("woff2-variations") is the legacy hint; modern engines also accept plain format("woff2") for variable files, but the explicit form is harmless and clearer. Setting font-display: swap gives you a 0ms block window so text never goes invisible, and font-optical-sizing: auto is the single line that hands opsz to the browser. A critical correctness note: font-variation-settings and font-optical-sizing are CSS properties applied to elements, not valid @font-face descriptors — placing them inside the @font-face block does nothing. Only font-weight, font-style, font-stretch, font-display, and src belong there.
Verification: Apply the stack, then select body text in DevTools and confirm font-optical-sizing: auto is computed and the Rendered Fonts line names InterVariable. Now select a heading at a much larger size and confirm the resolved opsz differs — you can read the active variation in the Fonts sub-tab of the Computed pane in recent Chrome. If both report the same opsz, the axis is not tracking and something downstream is pinning it.
Step-by-Step Workflow
Step 1 — Confirm the font actually has an opsz axis
Not every variable font ships an optical-size axis; many expose only wght. Inspect the fvar table before you build any CSS around opsz. Run python3 -m fonttools ttx -t fvar -o - InterVariable.woff2 (or the shorter ttx -t fvar font.woff2) and look for an <Axis> entry with <AxisTag>opsz</AxisTag>, noting its minValue/maxValue. Verify that the range you plan to use in CSS sits inside the foundry's tested bounds — driving opsz outside minValue–maxValue clamps to the edge and wastes the declaration.
Step 2 — Enable automatic optical sizing globally
Set font-optical-sizing: auto on :root so every element inherits it and opsz tracks the computed font-size with no per-element work. This is the correct default for the vast majority of text. Verify by comparing a 12px caption against a 48px heading in the Computed pane: the resolved optical instance should differ, with the larger size showing higher stroke contrast.
Step 3 — Override opsz only where intent diverges from size
font-optical-sizing: auto maps opsz to the rendered pixel size, which is right almost always. You override only when the intent differs from the size — for example a small "display-style" label that should keep high contrast, or a large heading you want to read with caption-grade legibility. Use font-variation-settings for the explicit value, and remember it sets the axis numerically, overriding auto.
Explicit opsz overrides for display and caption contexts
/* Large heading that should keep refined display contrast */
.display-heading {
font-variation-settings: "opsz" 36, "wght" 700;
}
/* Dense caption that needs caption-grade legibility */
.caption {
font-variation-settings: "opsz" 12, "wght" 400;
}
Verify by toggling the rule off and on in DevTools and watching the stroke contrast change on the affected element; if nothing visibly changes, the value is outside the axis range or the font lacks the axis. For a deeper, step-by-step treatment of pinning and reading these values, see controlling the opsz axis in variable fonts.
Step 4 — Beware the font-variation-settings reset trap
font-variation-settings is not additive — declaring it resets all axes not listed back to their defaults. If you set "opsz" 36 on a heading but forget to include "wght" 700, the weight snaps back to the default instance. Always list every axis you care about in each font-variation-settings declaration, or prefer the high-level properties (font-weight, font-optical-sizing) which compose cleanly. Verify by checking that an overridden heading still renders at its intended weight, not the font's default weight.
Step 4b — Map the width and slant axes through their high-level properties
If your font also exposes wdth or slnt, drive them with font-stretch (a percentage, e.g. font-stretch: 87.5% for a condensed instance) and, for slant, a font-variation-settings: "slnt" -10 value, since there is no dedicated high-level property for slnt. Prefer font-stretch over font-variation-settings: "wdth" so the value composes with the cascade and does not reset other axes. Verify by toggling font-stretch in DevTools and confirming the advance width of a word changes, then confirming wght and opsz are unaffected — proof you avoided the variation-settings reset trap.
Step 5 — Match fallback metrics to prevent swap CLS
The fallback system font has no optical-size axis, so when the variable font swaps in, the line box can reflow. Wrap a local fallback in a metric-tuned @font-face and apply size-adjust plus the override descriptors so the pre-swap render already matches. Pair this with deliberate fallback font stack design and the override math in font metrics & baseline alignment. Verify by throttling to Slow 3G, recording the swap in the Performance panel, and confirming the CLS attributed to the swap falls toward zero.
Step 6 — Subset and pin to shrink the payload
A full variable font carries every axis. If you only use wght and opsz, strip wdth, slnt, and ital before deploying, and pin axes to ranges you actually render. Verify with a before/after byte count from the DevTools Network panel and a ttx -t fvar dump confirming the dropped axes are gone.
Strip unused axes and pin ranges with the fonttools instancer
# Keep only the wght and opsz ranges you ship; drop everything else
python3 -m fonttools instancer \
--output-file=InterVariable.subset.ttf \
InterVariable.ttf \
"wght=300:700" "opsz=12:36"
# Then glyph-subset and re-compress to WOFF2
pyftsubset InterVariable.subset.ttf \
--unicodes="U+0000-00FF" \
--layout-features="kern,liga,calt" \
--flavor=woff2 \
--output-file=InterVariable.latin.woff2
Expect a 20–40% payload reduction when removing unused wdth and slnt axes, on top of the glyph-subset savings. Keep each subset under ~50KB where you can, and preload only the above-the-fold instance.
Step 7 — Audit against the performance budget
Treat the whole change as measurable. Capture font transfer bytes, LCP, and CLS from a Lighthouse run before and after, and wire a font-budget check into CI so a future commit that re-adds a stripped axis or pins opsz incorrectly trips the gate. Verify by diffing two Lighthouse JSON reports: the cumulative-layout-shift and largest-contentful-paint numbers should hold or improve, and total font bytes should drop.
Browser Compatibility & Fallback Matrix
| Feature | Chrome / Edge | Firefox | Safari | Notes |
|---|---|---|---|---|
Variable fonts (woff2-variations) |
62+ | 62+ | 11+ | Broadly supported on all current engines |
font-optical-sizing: auto |
79+ | 62+ | 13.1+ | Drives opsz from computed size |
font-variation-settings |
62+ | 62+ | 11+ | Resets unlisted axes to default |
@font-palette-values / font-palette |
101+ | 107+ | 15.4+ | Color-font palette control |
@property for animatable axes |
85+ | 128+ | 16.4+ | Enables transitions on registered axes |
Two edge cases deserve a flag. First, Safari historically resolved font-optical-sizing: auto correctly but lagged on @font-palette-values, so any color-axis branding must sit behind an @supports (font-palette: normal) gate with a static fallback. Second, because font-variation-settings is low-level and non-additive, mixing it with the high-level font-weight property on the same element is undefined-territory in older Firefox — prefer one approach per element. Re-check current support against your real analytics before depending on @property-driven axis animation for a load-bearing surface.
More Configuration Examples
Letting the browser drive opsz, with a metric-matched fallback
@font-face {
font-family: "InterVariable";
src: url("/fonts/InterVariable.latin.woff2") format("woff2");
font-weight: 300 700;
font-display: swap;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
:root {
font-family: "InterVariable", "Inter Fallback", system-ui, sans-serif;
font-optical-sizing: auto;
}
The metric-tuned Inter Fallback makes the pre-swap render match Inter's line box, so the variable font swapping in does not reflow text and CLS stays near zero while opsz interpolates per size.
Animating an axis safely with @property
@property --wght {
syntax: "<number>";
inherits: false;
initial-value: 400;
}
.hover-weight {
font-variation-settings: "wght" var(--wght), "opsz" 28;
transition: --wght 160ms ease-out;
}
.hover-weight:hover { --wght: 650; }
Registering the axis as a typed custom property lets the browser interpolate it smoothly instead of snapping. Note that opsz stays fixed here because we listed it explicitly — omit it and it would reset to the font default mid-transition.
Gating a color palette behind feature support
@font-palette-values --brand {
font-family: "BungeeColor";
override-colors: 0 #ff6b35, 1 #1f6f6b;
}
@supports (font-palette: normal) {
.logo { font-family: "BungeeColor"; font-palette: --brand; }
}
The @supports gate keeps non-supporting browsers on their default rendering instead of dropping the declaration into undefined behavior.
Common Pitfalls
- Pinning
opszeverywhere by accident. Declaringfont-variation-settings: "opsz" 16on:rootfreezes the axis for the whole page, undoing the per-size legibility the designer built. Usefont-optical-sizing: autoglobally and override only specific contexts. - Forgetting that
font-variation-settingsresets unlisted axes. Setting"opsz" 36without re-listing"wght"snaps weight back to default. List every axis you care about in each declaration. - Driving
opszoutside the foundry range. Values beyondminValue/maxValuefrom thefvartable clamp to the edge and waste the override; below ~12px many families also distort. Inspect withttx -t fvarfirst. - Treating
font-variation-settingsas an@font-facedescriptor. It is a CSS property applied to elements and has no effect inside@font-face. Onlysrc,font-weight,font-style,font-stretch,font-displaybelong there. - Ignoring fallback metrics. The fallback has no
opszaxis, so withoutsize-adjustand override descriptors the swap reflows the line box and scores CLS. Tune the fallback@font-face. - Shipping every axis. Leaving unused
wdth/slnt/italaxes in the file inflates the download by 20–40% for no rendering benefit. Strip them with thefonttoolsinstancer before deploy.
Frequently Asked Questions
How does the opsz axis differ from standard font-weight scaling?
opsz changes glyph proportions — stroke contrast, counter spacing, and detail — to suit a rendering size, so caption text gets sturdier strokes and display text gets refined ones. wght only changes stem thickness without altering structural geometry. They are independent axes: you can have heavy caption-optimized type or light display-optimized type, and a font with both lets you tune each separately.
Should opsz be pinned or left dynamic for web delivery?
Leave it dynamic with font-optical-sizing: auto for almost everything — it maps opsz to the computed size, which is exactly the behavior the type designer intended. Pin opsz with font-variation-settings only for the rare case where the visual intent diverges from the size, such as a small label you want rendered in display style or a large heading you want in caption style.
Does runtime axis interpolation hurt layout or paint performance?
Continuously updating font-variation-settings from JavaScript on every frame forces re-shaping and repaints and will tank your INP. Prefer CSS-only axis changes: font-optical-sizing: auto costs nothing per frame, and where you must animate an axis, register it with @property and use a CSS transition so the engine can interpolate it efficiently rather than thrashing layout from script.
Why is my variable font ignoring the weight range I set?
Almost always because font-weight in the @font-face block declares a single value instead of a range, which collapses the wght axis to one instance, or because a font-variation-settings declaration elsewhere resets wght to default by not listing it. Declare font-weight: 100 900 (a range) in @font-face, and list every axis you depend on in any font-variation-settings rule.
Can I animate the opsz axis on scroll or hover for an effect?
You can, but be deliberate about how. Register the axis as a typed custom property with @property and animate that through a CSS transition or @keyframes, feeding it into font-variation-settings, so the browser interpolates efficiently instead of you setting the property from a scroll handler every frame. Driving font-variation-settings directly from JavaScript on scroll or pointermove forces re-shaping on each event and will spike your INP past the 200ms target; keep axis animation in CSS and throttle any unavoidable script-driven changes.
Does optical sizing increase the font file size?
The opsz axis itself adds modest data — a few interpolation masters — but the bigger size driver is shipping all axes when you only render two. A full family with wght, wdth, opsz, slnt, and ital is meaningfully larger than one carrying just wght and opsz. Use the fonttools instancer to drop the axes you never set and pin the ones you do to their used ranges; the optical-size benefit is cheap, the unused axes are not.
Related
- Typography Fundamentals & System Architecture — the parent blueprint for this guide.
- Controlling the opsz axis in variable fonts — pin and read opsz values step by step.
- Best practices for optical sizing in CSS — debugging and CLS mitigation.
- Type Scale & Modular Grids — drive opsz across a coherent size ladder.
- Line Height & Vertical Rhythm — keep rhythm stable as optical metrics shift.