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;
}
wght axis range vs common usage Bar chart comparing the full declared wght axis range to the narrower range typically used in production. wght axis range vs common usage Full axis range 100-900 Declared @font-face 200-750 Body text 400 Headings 650-780 wght units
Most body and heading text uses only a narrow slice of the full weight axis.

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

  1. Inspect the font's registered axes. Run fonttools varLib.instancer --info InterVariable.woff2 or 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").
  2. Map registered axes to CSS properties. wghtfont-weight, wdthfont-stretch, slnt/italfont-style. Anything else (opsz aside — see the optical sizing guide) stays in font-variation-settings.
  3. Declare the real axis ranges in @font-face, not the full theoretical range the font supports — clamp font-weight to 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.
  4. Set defaults on body/:root with font-weight and font-stretch, so every element inherits a sane baseline instance without an explicit rule.
  5. Layer font-variation-settings only where you need multi-axis or unregistered-axis control, scoped to the smallest selector that needs it (a hero heading class, not *).
  6. Verify computed axis coordinates in DevTools — the Computed panel's font section shows the resolved font-variation-settings string, including values inherited from the shorthand properties, so you can confirm what actually got sent to the rasterizer.
  7. Test at both axis extremes and mid-range — a wght of 900 combined with wdth of 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.
Axis tuning workflow Numbered process diagram for tuning a new variable font's axes. Axis tuning workflow 1 Inspect axes varLib.instancer --info 2 Map to CSS wght, wdth, slnt 3 Declare ranges @font-face 4 Set defaults body, :root 5 Layer overrides font-variation-settings 6 Check DevTools computed axes 7 Test extremes min/mid/max
Seven steps from inspecting a variable font to verifying computed axis values.

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.

Registered variable font axes Four headline stats naming the registered variable font axes and their CSS mapping. Registered variable font axes wght font-weight wdth font-stretch slnt font-style: oblique ital font-style: italic
Four axes are registered and map to standard CSS properties.

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 900 in @font-face when the file only ships default-to-900. The browser will accept out-of-range font-weight requests 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 against fonttools varLib.instancer --info.
  • Animating raw font-variation-settings strings instead of matching axis lists. A transition or @keyframes block that goes from "wght" 400 to "wght" 700, "wdth" 100 cannot 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: bold or <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-face plus 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 (wght below 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-motion on animated axis values. A wght or slnt transition 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.

Related