Variable Font Axis Tuning: wght, wdth & slnt
This guide is part of the Typography Fundamentals & System Architecture area.
A variable font ships every weight, width, and slant a typeface designer built as interpolation
data inside a single file, but a browser only renders whatever axis coordinates your CSS actually
requests. If you drop a variable .woff2 into @font-face and stop there, you get one static
instance — usually whatever the font's default named instance is — and none of the size, payload,
or design benefit the format promises. Axis tuning is the work of registering the axes a variable
font exposes, driving them deliberately with font-variation-settings or the standardized
shorthand properties, and constraining the ranges you actually ship so the browser never has to
guess. This overview covers the three most common non-optical axes — wght (weight), wdth
(width), and slnt (slant) — how they differ from the ital axis and from optical sizing,
and how to keep animated or user-adjustable axis values from breaking legibility or interpolation.
Why axis tuning is a distinct problem from font loading
Getting a variable font onto the page is a font loading
problem: format negotiation, font-display, preload timing. Axis tuning starts after the file has
arrived and the @font-face rule has matched. It is the CSS-level decision of which point, or
range of points, inside the font's design space actually gets painted. Two sites can preload the
exact same 140 KB variable weight axis file and render completely different results — one pins
wght: 400 for body text and never moves, the other exposes a live weight slider bound to
font-variation-settings: "wght" var(--w). Both are valid uses of the same binary; the difference
is entirely in how the axes are addressed.
Axis registration matters because browsers only expose the four registered axes — wght, wdth,
slnt, and ital — through dedicated CSS properties (font-weight, font-stretch,
font-style: oblique <angle>, font-style: italic). Any other axis a foundry defines, and any
value outside the axis's declared min/max, must go through the low-level
font-variation-settings property, which does not participate in font matching or fallback
substitution the way the high-level properties do.
Baseline configuration
Start every variable font integration with an explicit @font-face block that declares the axis
ranges the file actually supports, using a range syntax for font-weight and font-stretch
rather than a single value:
@font-face {
font-family: "Inter Variable";
src: url("/fonts/InterVariable.woff2") format("woff2-variations");
font-weight: 100 900;
font-stretch: 75% 125%;
font-style: oblique 0deg 10deg;
font-display: swap;
}
:root {
--font-body: "Inter Variable", system-ui, sans-serif;
}
body {
font-family: var(--font-body);
font-weight: 400;
font-stretch: 100%;
}
h1, h2, h3 {
font-weight: 650;
}
The format("woff2-variations") hint (fall back to plain format("woff2") for older Safari
versions that don't parse the hint) tells the browser this file is a variation font. The range on
font-weight and font-stretch is not decorative — it is what lets the browser's font matcher
accept a font-weight: 650 request without falling back to synthetic bold, because 650 sits inside
the declared 100 900 range and the axis is registered.
For anything the two high-level properties can't reach — a numeric wght value combined with a
custom wdth or slnt in one shorthand, or an unregistered foundry-specific axis like GRAD or
YOPQ — drop to font-variation-settings:
.display-heading {
font-variation-settings: "wght" 720, "wdth" 112, "slnt" -3;
}
Two rules govern mixing the two systems. First, font-variation-settings always overrides the
computed value from font-weight/font-stretch/font-style for axes it names explicitly — so if
you set both, the shorthand wins for anything it touches, which makes debugging painful if you
forget it's there. Second, font-variation-settings does not interpolate during CSS transitions
by default in the same additive way discrete keyframes do; you need matching axis lists on both
ends of a transition or animation for it to tween smoothly rather than jump.
Step-by-step workflow for tuning a new variable font
- Inspect the font's registered axes. Run
fonttools varLib.instancer --info InterVariable.woff2or open the file in a variable-font inspector to list every axis tag, its min/default/max, and any named instances the foundry shipped (e.g. "SemiBold", "Condensed"). - Map registered axes to CSS properties.
wght→font-weight,wdth→font-stretch,slnt/ital→font-style. Anything else (opszaside — see the optical sizing guide) stays infont-variation-settings. - Declare the real axis ranges in
@font-face, not the full theoretical range the font supports — clampfont-weightto the range your type scale actually uses (e.g.350 750) so the browser's matching algorithm never has to extrapolate outside tested design-space regions. - Set defaults on
body/:rootwithfont-weightandfont-stretch, so every element inherits a sane baseline instance without an explicit rule. - Layer
font-variation-settingsonly where you need multi-axis or unregistered-axis control, scoped to the smallest selector that needs it (a hero heading class, not*). - Verify computed axis coordinates in DevTools — the Computed panel's font section shows the
resolved
font-variation-settingsstring, including values inherited from the shorthand properties, so you can confirm what actually got sent to the rasterizer. - Test at both axis extremes and mid-range — a
wghtof 900 combined withwdthof 75% can trigger interpolation artefacts (overlapping strokes, broken counters) that never show up at default coordinates; check the foundry's documented safe combinations before shipping a multi-axis preset.
Registered axis reference and browser support
| Axis | CSS property | Typical range | Registered? | Fallback behaviour |
|---|---|---|---|---|
wght |
font-weight |
1–1000 (declared subset e.g. 100–900) | Yes | Matches nearest declared weight in family |
wdth |
font-stretch |
Percentage, e.g. 50%–200% | Yes | Matches nearest declared stretch keyword/percent |
slnt |
font-style: oblique <angle> |
Degrees, e.g. -15deg–0deg | Yes | Synthesizes oblique if unsupported |
ital |
font-style: italic |
Binary 0/1 | Yes | Falls back to a separate italic file or synthetic slant |
opsz |
font-optical-sizing (auto only) or font-variation-settings |
Points, e.g. 8–144 | Yes (but auto-linking is size-based, not a CSS value you set numerically) | Falls back to default instance if auto unsupported |
Custom (GRAD, YOPQ, …) |
none — font-variation-settings only |
Foundry-defined | No | No matching; ignored by non-variable fallback fonts entirely |
All four registered axes and font-variation-settings have been supported in Chrome, Firefox, and
Safari since roughly 2018–2020 (Safari 11+, Chrome 62+, Firefox 62+ for the property; wide-axis
CSS keyword mapping arrived slightly later per-browser). The practical compatibility risk today is
not "does the browser support variable fonts" — it's whether the specific named-instance mapping
and axis range you declared in @font-face matches what the font file actually ships, which no
browser validates for you.
Labelled code examples
Example 1 — a bounded weight axis for body copy and headings, without a slider
@font-face {
font-family: "Source Sans Variable";
src: url("/fonts/SourceSansVariable.woff2") format("woff2-variations");
font-weight: 200 900;
font-display: swap;
}
body { font-weight: 400; }
strong, b { font-weight: 620; }
h1 { font-weight: 780; }
This is the common case: no JavaScript, no custom axes, just letting font-weight address the
wght axis directly with values the type system has chosen deliberately instead of the browser's
default bold keyword (which usually resolves to 700, a value that may look too heavy or too
light depending on the specific font's interpolation curve at that point).
Example 2 — a live weight and width control bound to CSS custom properties
.tunable-heading {
--w: 500;
--s: 100;
font-variation-settings: "wght" var(--w), "wdth" var(--s);
transition: font-variation-settings 200ms ease-out;
}
const heading = document.querySelector(".tunable-heading");
weightSlider.addEventListener("input", (e) => {
heading.style.setProperty("--w", e.target.value);
});
Custom properties can sit inside a font-variation-settings value list because the property
accepts arbitrary computed values per axis — this is the standard pattern for weight/width
sliders in variable-font demos and design tools, and it composes cleanly with the transition
declared on the same property.
Example 3 — a condensed-width utility class for space-constrained UI
.condensed {
font-variation-settings: "wdth" 82;
}
@supports (font-stretch: 82%) {
.condensed {
font-variation-settings: normal;
font-stretch: 82%;
}
}
Preferring font-stretch when it's supported keeps the axis participating in normal font
matching and CSS cascade shorthand behaviour (font: ... shorthand resets it correctly); the
@supports fallback only exists for older engines where the percentage syntax on font-stretch
wasn't yet wired to the wdth axis.
Example 4 — clamping slant for a hybrid italic/oblique family
em, i {
font-style: italic;
}
.slight-lean {
font-style: oblique 6deg;
}
If the variable font registers both ital and slnt, prefer font-style: italic for true
italic design (different letterforms, not just a skew) and reserve oblique <angle> for a
mechanical slant on the upright glyphs — conflating the two produces text that reads as
"slanted roman" where a reader expects genuine italic forms, which is a legibility regression
in body copy.
Named instances vs live axis values
Most variable font files ship a list of "named instances" — fixed axis coordinate sets the
foundry considered worth naming, like "Regular" (wght 400), "SemiBold" (wght 600), or
"Condensed Bold" (wght 700, wdth 75%). These names exist in the font's fvar table purely as
metadata for design tools; a browser does not expose them as CSS keywords, and font-weight: semibold is not a valid way to invoke one. You still have to translate a named instance's axis
coordinates into either a font-weight/font-stretch pair or an explicit
font-variation-settings list. The practical benefit of knowing the named instances is that they
represent combinations the type designer actually tested and intended to look correct — an
arbitrary point you pick yourself, like wght 550 combined with wdth 90%, may fall inside the
font's designed range without ever having been checked for rendering quality at that specific
intersection. When you introduce a new multi-axis value into a design system, cross-reference it
against the nearest named instance and inspect the rendered glyphs at representative sizes before
locking it into a design token.
This distinction matters most for teams building a component library on top of a variable font.
If your button, heading, and badge components each pick a slightly different wght value
independently — 590 here, 610 there — you accumulate axis drift that produces visually
inconsistent weight across a page for no design reason, and it becomes difficult to reason about
which value is "the semibold" for the system. Treat each named instance you decide to use as a
design token (--font-weight-regular: 400, --font-weight-semibold: 600,
--font-weight-bold: 700) exactly the way you would treat a color or spacing token, and reference
only those tokens from component CSS. This keeps the number of distinct axis coordinates in
production small, which also keeps the rendering engine's glyph cache warmer across the page —
each unique coordinate combination the browser encounters is effectively a distinct font instance
it has to interpolate and rasterize independently.
Testing axis coordinates across rendering engines
Variable font interpolation is not guaranteed to produce pixel-identical output across Chromium,
WebKit, and Gecko, because each engine's text rasterizer (Skia, CoreText, and the platform's
FreeType/DirectWrite equivalent respectively) implements its own interpolation and hinting
pipeline on top of the same fvar/gvar table data. The axis coordinates you request are
identical, but sub-pixel positioning, hinting at small sizes, and antialiasing can differ enough
that a wght of 620 looks very slightly heavier in one engine than another. This is rarely a
problem for body text at typical reading sizes, but it becomes visible in large display headings
where a viewer might compare two browser windows side by side, or in design QA where a Figma
export is compared directly against a live page.
Build a small visual-regression fixture — a static HTML page with every axis coordinate your design system actually uses, each in its own labelled block — and capture it with a screenshot tool across at least Chromium and WebKit before shipping a new multi-axis preset. This is far cheaper than debugging a support ticket about "the heading looks thinner on Safari" months after launch, and it gives you a concrete artifact to diff against on every font file update, since foundries do occasionally revise interpolation data in point releases without changing the axis ranges at all.
Multi-axis presets and how they interact with the type scale
Axis tuning rarely happens in isolation from the rest of a typographic system. A
type scale built on
clamp() or modular ratios typically pairs specific weight values with specific size steps —
display headings at a heavier wght and a mild negative wdth to keep condensed line lengths at
very large sizes, body text at a lighter wght with wdth at 100%. When you introduce a new
multi-axis preset, verify it against the metrics work already done for that size step: a heavier
weight or a narrower width can change a glyph's advance width enough to shift where a
baseline grid
column wraps, even though the font-size and line-height are untouched. This is a second-order
effect that only variable fonts introduce, since a static weight family locks the metrics for each
weight once at build time, while a variable font's metrics can shift continuously as you move an
axis.
A practical way to bound this is to declare a small, closed set of "axis presets" as CSS custom
properties at the :root level — for example --preset-display: "wght" 720, "wdth" 96; and
--preset-body: "wght" 400, "wdth" 100; — and apply them only through
font-variation-settings: var(--preset-display). This keeps the number of distinct rendered
instances on any given page small and auditable, and it means a later adjustment to a preset (a
type lead deciding the display weight should be 700 instead of 720) is a single-line change
instead of a search-and-replace across every component that hardcoded the value.
Common Pitfalls
- Declaring
font-weight: 400 900in@font-facewhen the file only ships default-to-900. The browser will accept out-of-rangefont-weightrequests silently and clamp or extrapolate rather than erroring, producing subtly wrong renders at low weights that never got tested by the foundry. Always cross-check the declared range againstfonttools varLib.instancer --info. - Animating raw
font-variation-settingsstrings instead of matching axis lists. Atransitionor@keyframesblock that goes from"wght" 400to"wght" 700, "wdth" 100cannot interpolate cleanly because the axis lists differ in length — always list every axis you intend to touch on both the start and end state, even at its default value. - Using
font-weight: boldor<strong>styling with no explicit numeric override. The user-agent default bold synthesis (or the font's own 700 instance) is frequently a worse choice than a value tuned for that specific typeface's interpolation curve — a 620 or 640 weight often reads better as "bold enough" without the visual jump some 700 instances produce. - Shipping every named instance as a separate static
@font-faceplus the variable file. This doubles the download for users who only need the variable axis coordinates, and it defeats the entire size argument for using a variable font — see the size/count trade-off in variable fonts vs static weights. - Ignoring accessibility floors on
wght. Thin variable weights (wghtbelow roughly 300) fail contrast perception for low-vision readers even when hex contrast ratios pass WCAG, because stroke thickness itself carries legibility information — see clamping variable font weight for accessibility for a concrete floor value and enforcement pattern. - Skipping
prefers-reduced-motionon animated axis values. Awghtorslnttransition triggered on hover or scroll is still motion, and some users experience continuously interpolating glyph shapes as more disorienting than a moving box — gate any axis animation the same way you'd gate a transform, detailed in animating font weight with variable fonts.
Frequently Asked Questions
Do I need font-variation-settings if I only ever use font-weight and font-stretch?
No. If your axis usage stays within wght, wdth, slnt, and ital, and every value you need
sits inside a single numeric or keyword value those four properties can express, the high-level
properties are sufficient and preferable — they participate in font matching, inherit and cascade
normally, and compose with the font shorthand. Reach for font-variation-settings only for
custom axes or for setting multiple registered axes in one declaration for performance reasons.
Why does my wght value get ignored when I also set font-variation-settings?
font-variation-settings takes precedence over the high-level properties for any axis it lists
explicitly, per the CSS Fonts Level 4 specification. If a stylesheet further down the cascade (or
a component library's default styles) sets font-variation-settings: "wght" 400 on the same
element, it silently overrides your font-weight: 700 even though font-weight appears to be a
more specific-looking rule. Search your computed styles for any font-variation-settings
declaration before debugging a "wrong weight" bug.
Can I mix slnt and ital axes on the same variable font?
Yes, if the foundry registered both, but they are semantically different: ital toggles between
distinct upright and italic letterform designs (a discrete 0 or 1, sometimes fractional during
interpolation), while slnt mechanically shears the upright glyphs by a continuous angle. Use
font-style: italic to invoke ital and reserve oblique <angle> for slnt; setting both axes
via font-variation-settings at once is valid but rarely intentional design.
Does axis tuning affect file size or network transfer? No — every axis coordinate you request is rendered client-side from data already inside the downloaded variable font file. Tuning is a zero-additional-bytes operation once the file has loaded; the size cost was paid once when you chose to ship a variable font instead of N static weights, a trade-off covered in the variable font loading techniques guide.
Should I clamp font-variation-settings values with clamp() or a media query?
Prefer bounding axis values with a fixed set of presets tied to breakpoints or container size
rather than trying to interpolate an axis continuously from viewport width the way you might with
a fluid type scale. A continuously fluid wdth or wght tied directly to viewport width can
produce a page that visibly "breathes" as a user resizes a window, which reads as an unintentional
animation rather than deliberate responsive design; a small number of discrete presets switched at
defined breakpoints gives you the same responsive intent with a predictable, testable result at
each breakpoint.