width-axis headline fitting


npm ↗
GitHub ↗
TypeScriptZero dependenciesReact + Vanilla JS

Fit Width fits a one-line headline to a target width. It searches the font’s wdth axis first, at the size you set, then closes what is left with letter-spacing.

In most of the fonts we measured, the axis is a modest adjustment. Across 21 Google Fonts families with a wdth axis, half couldn’t take a headline below 80% of its natural width over 75–125, and half couldn’t take it past 113%; families with a long width range go much further. When a box is further away than the axis can reach, something else is doing the fitting. The demo below shows what, for any headline and box you choose.

The measurements are written up in a paper and a short talk, with the data.

Live demo: one headline, one box, four strategies

Font size you set64 px
Box width45%
wdth range searched75–125

A wide axis (25–151) that also has an optical-size axis, so its reach changes with font size. 75–125 is fitWidth’s default search range, not a property of the font (on Google Fonts it is the most common wdth range after Noto’s 62.5–100: 24 of 97 families). This font has more, and “Font’s full range” searches all of it.

How far each lever reaches

Measuring…

The same reach in 21 font families

How far wdth 75–125 moves a headline in 21 Google Fonts families that have the axis, as a share of its natural width. Half of them can’t take it below 80%, and half can’t take it past 113% (two separate medians: no single font has exactly that range). Six can’t widen at all; the 15 that can reach a median of 119%. A few have a long width range and go much further.

Scale: 50% to 150%. Dashed line: natural width (100%). Shaded band: the median, 80–113%. Each font was searched over 75–125 or as much of that as it has. Mean of five headline strings at 72 px, weight 400, measured in Chromium 149 on 7 October 2026; these 21 are a hand-picked sample of the 97 Google Fonts families with a wdth axis, and the sample is kinder to the axis than the whole set: 6 of these 21 can’t widen, against 49 of the 97. This chart is fixed data. The ruler above is live, so its numbers differ with your headline and size.

We also ran the library on these 21 fonts: one headline (“Headline fitting”, 72 px) and 16 target widths, from 0.5× to 2× its natural width in steps of 0.1×, which makes 336 targets. With wdth alone, 75 of the 336 fit. That count includes the 21 targets at exactly 1.0×, where nothing has to move: without them it is 54 of 315. Tracking alone (±0.3em) fit 267, wdth then tracking 294, and wdth then font size then ±0.05em fit all 336 (the targets stop at 2×, which is also where the size option stops). Those counts describe this grid of targets, not headlines in general.

One headline, one box, four strategies

Width axis only

prefer: 'axis'

May change: wdth.

Typography

wdth
—
font size
64 px, fixed
tracking
not used
result
Measuring…

Tracking only

prefer: 'tracking'

May change: letter-spacing, up to ±0.3em.

Typography

wdth
not used
font size
64 px, fixed
tracking
—
result
Measuring…

Width axis, then tracking

prefer: 'auto' (what the package does by default)

May change: wdth first, then letter-spacing up to ±0.3em.

Typography

wdth
—
font size
64 px, fixed
tracking
—
result
Measuring…

Width axis, then font size, then a little tracking

size: truerecommended · opt-in, new in 1.2.0

May change: wdth first, then font size from 0.5× to 2×, then letter-spacing up to ±0.05em. This is the order we recommend. It is opt-in: the default never changes font size.

Typography

wdth
—
font size
—
tracking
—
result
Measuring…

Roboto Flex has an optical-size axis that follows font size, so a 2× font size is not 2× the width (the “×” beside a font size is the change in width, not in size), and its wdth reach changes with size: drag “Font size you set” and watch the ruler. Each “×” is how much that step changed the headline’s width; multiply them and you get the fitted width over the width as set. Widths here are the element’s measured (advance) width. In a fit that width is inside the box and at most half a pixel from its edge. Two things sit inside it: the letter-spacing a browser adds after the last letter (each row says how much), and the last letter’s own side bearing, as in any text. When every range a strategy may use has run out, the row says how far short (or over) it ended: fitWidth stops there and does not force the fit. (An overflow also prints a console warning, once for each combination of levers; falling short prints nothing.) No row always wins: the last one stops at half and double the size you set, so a very narrow box can still overflow it where ±0.3em of tracking squeezes in. The numbers under each row come from the object applyFitWidth returns, new in 1.2.0. The first three rows write the same styles 1.1.0 did (checked on 1,008 fits); the last row uses the size option, also new in 1.2.0 and off by default.

How it works

The width axis is a modest adjustment

A wdth value is not a percentage of width you can count on. Roboto Flex at wdth 75 is 83% as wide at 72 px and 96% as wide at 14 px, because its optical size changes the reach. Of the 97 Google Fonts families with a wdth axis, 49 stop at 100 and can’t widen at all. In most of them, plan on the axis for the last 10–20% of a fit. Families with a long width range are the exception: Anybody reaches 68–132% over the same 75–125. A wdth step also changes the spacing: the designer drew sidebearings and kerning for each width.

Three levers, in a fixed order

First the axis, at the font size you set (default search range 75–125). Then, only if you turn on size, font size from 0.5× to 2× (new in 1.2.0). Then letter-spacing, capped at ±0.3em, or ±0.05em when size is on. prefer can restrict it to the axis or to tracking alone.

The package’s default stops at the axis and tracking: font size never changes unless you ask. We think font size is the better second step, because it doesn’t override the designer’s spacing or switch off ligatures, and we recommend turning size on. It costs height, and it is not a superset of tracking: at its 0.5× floor it can overflow a box that −0.3em of tracking would squeeze into.

It measures a hidden copy

Each stage is a binary search of about 20 measurements on a hidden copy of your element, placed beside it so it renders the same way (nested markup, your own spacing, text-transform). The visible element is written once, at the end. useFitWidth and FitWidthText refit on container resize and when fonts load.

It tells you when it can’t

A fit’s measured (advance) width is never wider than its target and at most tolerance (0.5 px) narrower; a letter’s ink can overhang that by a few pixels, as in any text. When the ranges you allow can’t reach the target, the text is left short or overflowing; it isn’t forced. An overflow prints a console warning, once for each combination of levers; falling short prints nothing. Since 1.2.0 applyFitWidth also returns what each stage did for every fit, which is what the demo prints.

Limits

  • One line only. A <br> makes two lines and the fit follows the longer one.
  • The font must be loaded before the vanilla API runs; a fit measured in a fallback font is wrong. Call it after document.fonts.ready. The hook and component do this for you.
  • Values outside a font’s own wdth range are clamped by the browser, so searching 75–125 in a font that has 75–100 finds nothing above 100.
  • Any non-zero letter-spacing turns off a font’s ligatures. If your headline has one (an “fi”, say), the width jumps when tracking starts, and a target inside that jump can’t be reached by tracking.
  • At −0.3em letters can collide. Lower maxTracking if a narrow box is possible.
  • Browsers add letter-spacing after the last letter as well as between letters, and the fit counts that space. So a tracked fit’s last letter ends short of the edge by the tracking amount (or past it, with negative tracking): a median of 8.5 px in our 21-font test, and up to 22 px. An opt-in, trimTrailingSpace, counts spacing between letters only and cancels the trailing space with a margin. It only applies to elements that size themselves to their text, such as display: inline-block (new in 1.2.0).
  • The search assumes the axis widens the text as its value rises. An axis that doesn’t (opsz) won’t converge, and a few fonts break the rule for some letters: in Mona Sans, “illicit” gets narrower above wdth 100.
  • Width also changes stroke weight and proportion in most families. Headlines seen together at very different widths can read as different fonts: keep a set within about one width class.
  • size changes the element’s height. Text that is scaled to fit is also a known way to fail WCAG 1.4.4 (Resize Text); the CSS Working Group is discussing a default 200% limit for that reason, and size: true stops at 2×.

When CSS is the simpler choice

If you only want the text scaled to the box, you may not need a script. The CSS text-fit property (CSS Text Level 5 draft) does it natively. In Chromium 149 it works only with experimental web platform features turned on, so today a fluid font-size in container units gets you close without JavaScript. Fit Width is for the case those don’t cover: holding the size you set and letting the font’s own widths take up the slack.

/* Scales the line up to fill its container (CSS Text Level 5 draft).
   Chromium 149: behind "experimental web platform features". */
h1 { text-fit: grow per-line-all 200%; }

Usage

TypeScript + React · Vanilla JS

Drop-in component

import { FitWidthText } from '@overpunch/fitwidth'

<FitWidthText as="h1" style={{ whiteSpace: 'nowrap' }}>
  Display Headline
</FitWidthText>

Hook: attach to any element

import { useFitWidth } from '@overpunch/fitwidth'

const ref = useFitWidth({ axisMin: 75, axisMax: 100 }) // your font's own wdth range
<h1 ref={ref}>Display Headline</h1>

Vanilla JS

import { applyFitWidth, removeFitWidth } from '@overpunch/fitwidth/core'

const el = document.querySelector('h1')
await document.fonts.ready
applyFitWidth(el)

// Restore original styles
removeFitWidth(el)

Let font size take over, and read what the fit did (1.2.0)

// Since 1.2.0. size is opt-in: without it, font size is never changed.
const result = applyFitWidth(el, { size: true })

result.status   // 'fit' | 'short' | 'overflow'
result.ratios   // { axis: 1.17, size: 1.39, tracking: 1 }  width multipliers
result.limits   // { axis: 'max', size: null, tracking: null }

Options

OptionDefaultDescription
target'container'Width to fit: 'container' (the parent's content box, without padding or borders), a number of px, or an HTMLElement.
prefer'auto''auto': the axis first, then letter-spacing. 'axis': the axis only. 'tracking': letter-spacing only.
axis'wdth'Variable font axis tag to search. It must widen the text as its value rises.
axisMin75Lowest axis value searched. Set it to your font's own minimum if that is higher.
axisMax125Highest axis value searched. Set it to your font's own maximum if that is lower.
maxTracking0.3Most letter-spacing the fit may add or remove, in em, on top of your own. 0.05 when size is on.
tolerance0.5How many px narrower than the target a fit may be. It is never wider.
sizefalseSince 1.2.0; off by default. true lets font size go from 0.5× to 2× when the axis runs out; { min, max } sets your own multipliers.
trimTrailingSpacefalseSince 1.2.0. Counts letter-spacing between letters only, so a tracked fit's last letter lands on the target. Applies to elements that size themselves to their text; ignored with a warning elsewhere.
onFitnoneSince 1.2.0. Called after each fit with the result: widths, the value each stage ended on, and whether the text fits.
respectReducedMotionfalseWhen true, skips fitting if the user has enabled prefers-reduced-motion.

FitWidthText only: as (default 'h1'), the HTML element to render.

no-code

Use it in Webflow, Framer & Figma

The same effect, no build step — drop it straight into your design tool.

Webflow

One script tag, then mark any element with data-fitwidth. Configure it with data-* attributes.

<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/fitwidth/dist/fitwidth.webflow.min.js"></script>

<!-- Then add data-fitwidth to any text element -->
<h1 data-fitwidth>Your headline</h1>

Framer

Insert → Code → New Component, then paste FitWidth.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.

import { /* core */ } from "https://esm.sh/@overpunch/fitwidth"

Figma · beta

Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it works with compromises — tracking or named-instance swaps (Figma can't set variable axes).