width-axis headline fitting
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
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
wdthrange 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
maxTrackingif 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 asdisplay: 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 abovewdth100. - 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.
sizechanges 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, andsize: truestops 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
| Option | Default | Description |
|---|---|---|
| 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. |
| axisMin | 75 | Lowest axis value searched. Set it to your font's own minimum if that is higher. |
| axisMax | 125 | Highest axis value searched. Set it to your font's own maximum if that is lower. |
| maxTracking | 0.3 | Most letter-spacing the fit may add or remove, in em, on top of your own. 0.05 when size is on. |
| tolerance | 0.5 | How many px narrower than the target a fit may be. It is never wider. |
| size | false | Since 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. |
| trimTrailingSpace | false | Since 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. |
| onFit | none | Since 1.2.0. Called after each fit with the result: widths, the value each stage ended on, and whether the text fits. |
| respectReducedMotion | false | When 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).