Skip to content

Liiift-Studio/fitWidth

Repository files navigation

fitWidth

npm License: MIT part of liiift type-tools

CSS has no native way to stretch or compress a display headline to fill an exact container width without changing font-size. fitWidth binary-searches the wdth variable font axis — and falls back to letter-spacing — to close that gap precisely. Type size stays constant; only inter-glyph geometry changes.

The word Typography rendered at one font size in three containers of decreasing width — 100%, 64%, and 40% — each filled flush to the edge by condensing the wdth variable font axis.

▶ Try the live demo at fitwidth.com — drag a slider and watch headlines re-fit in real time.

npm · GitHub

TypeScript · Zero dependencies · React + Vanilla JS


Install

npm install @liiift-studio/fitwidth

Requirements: any modern browser. The core relies on getBoundingClientRect, font-variation-settings, and (for live re-fitting) ResizeObserver and document.fonts.ready — all available in current Chrome, Edge, Firefox, and Safari. React is an optional peer dependency (>=17); the vanilla API needs no framework.


Usage

Next.js App Router: this library uses browser APIs. Add "use client" to any component file that imports from it.

Variable font recommended: fitWidth works best with a variable font that has a wdth axis — for example Roboto Flex, Recursive, or Inter. When a wdth axis is available, prefer: 'auto' uses it for the main fit and refines with letter-spacing only if needed. With a static font, the algorithm falls back to letter-spacing alone (prefer: 'tracking') — the result is functional but typographically coarser.

Single line, single element: fitWidth fits one display element to one target width — a headline, masthead, or pull-quote on a single line. It does not wrap or re-flow multi-line body copy, and it never splits content into per-word or per-line spans. Keep the element on one line (white-space: nowrap) for predictable results.

React component

import { FitWidthText } from '@liiift-studio/fitwidth'

<FitWidthText as="h1" axis="wdth" axisMin={75} axisMax={125}>
  The quick brown fox
</FitWidthText>

The default as element is 'h1'. Pass any valid HTML element type — 'h2', 'p', 'div' — to render a different tag.

React hook

import { useFitWidth } from '@liiift-studio/fitwidth'

// Inside a React component:
const ref = useFitWidth({ axis: 'wdth', axisMin: 75, axisMax: 125 })
return <h1 ref={ref}>The quick brown fox</h1>

The hook re-runs automatically on resize via ResizeObserver and after fonts finish loading via document.fonts.ready. It cleans up the observer on unmount.

Vanilla JS

import { applyFitWidth, removeFitWidth } from '@liiift-studio/fitwidth'

const el = document.querySelector('h1')
const opts = { axis: 'wdth', axisMin: 75, axisMax: 125 }

function run() {
  applyFitWidth(el, opts)
}

run()
document.fonts.ready.then(run)

let ro = new ResizeObserver(() => run())
ro.observe(el)

// Later — disconnect and restore original inline styles:
// ro.disconnect()
// removeFitWidth(el)

TypeScript

import type { FitWidthOptions } from '@liiift-studio/fitwidth'

const opts: FitWidthOptions = {
  target: 'container',
  prefer: 'auto',
  axis: 'wdth',
  axisMin: 75,
  axisMax: 125,
  maxTracking: 0.3,
  tolerance: 0.5,
}

Options

Option Default Description
target 'container' Width to fill. 'container' fills the parent element's getBoundingClientRect().width (a sub-pixel float, CSS-transform–aware). Pass a number for an exact pixel target. Pass an HTMLElement to match the rendered width of another element
prefer 'auto' Strategy to use. 'auto' tries the wdth axis first, then refines with letter-spacing if needed. 'axis' uses the axis only (sets letter-spacing to 0 first). 'tracking' uses letter-spacing only and leaves font-variation-settings unchanged
axis 'wdth' Variable font axis tag to adjust when prefer is 'auto' or 'axis'. Any four-character OpenType axis tag is valid (e.g. 'wdth', 'wght', 'XTRA')
axisMin 75 Minimum axis value for the binary search
axisMax 125 Maximum axis value for the binary search
maxTracking 0.3 Maximum absolute letter-spacing in em. The result is clamped to ±this value
tolerance 0.5 Convergence tolerance in pixels. The search stops when the remaining gap is within this value
respectReducedMotion false When true, checks prefers-reduced-motion: reduce before fitting. If the user has enabled reduced motion, applyFitWidth returns early without modifying any styles. The React hook also listens for OS-level changes to the preference and re-evaluates automatically
as 'h1' HTML element to render. Accepts any valid React element type. (React component only)

How it works

CSS leaves a display headline ragged inside its container; applyFitWidth closes the gap so the headline sits flush to both edges — same font, same size.

The same headline Display Type in two identical containers: above, plain CSS leaves a large gap on the right; below, applyFitWidth expands the wdth axis so the text reaches both edges.

Binary search algorithm: applyFitWidth reads the element's current width using getBoundingClientRect(), then bisects the search space up to 20 times per pass. Each iteration sets el.style.fontVariationSettings or el.style.letterSpacing directly and re-measures. The loop exits early once the gap falls within tolerance pixels.

prefer: 'auto' strategy: The axis search runs first. If the best axis value still leaves a gap larger than tolerance — because the target is outside the font's axis range — a second binary search over letter-spacing runs from the current position to close the remaining difference. Axis variation is always preferred over tracking when available, because it preserves the designer's intended glyph shapes.

The three prefer modes fill the same width by different means — 'axis' widens the glyphs themselves, 'tracking' widens the gaps between them, and 'auto' uses the axis first and only falls back to tracking when the axis range runs out:

The word Headline fitted to one width three ways: prefer axis gives wider letterforms, prefer tracking keeps the default letterforms with wider spacing between them, and prefer auto uses the wdth axis.

No innerHTML rewriting: Unlike line-based tools in this suite, fitWidth operates on a single element and never wraps content in spans or rewrites innerHTML. It modifies only el.style.fontVariationSettings and el.style.letterSpacing. The original inline values are saved in a WeakMap on the first call; subsequent calls reset from those saved values before re-fitting, making repeated invocations idempotent. removeFitWidth restores the saved originals and clears the entry.

ResizeObserver built in: The React hook and Vanilla JS example both observe the container with ResizeObserver. Callbacks are debounced with requestAnimationFrame and deduplicated by integer pixel width — the fit only re-runs when the container actually changes width.

document.fonts.ready timing: Browser width measurements before a web font loads return metrics for the fallback font, producing an incorrect fit. The hook and Vanilla JS example both call document.fonts.ready.then(run) to re-run once the real font is available.


Dev notes

next in root devDependencies

package.json at the repo root lists next as a devDependency. This is a Vercel detection workaround — not a real dependency of the npm package. Vercel's build system inspects the root package.json to detect the framework; without next present it falls back to a static build and skips the Next.js pipeline, breaking the /site subdirectory deploy.

The package itself has zero runtime dependencies. Do not remove this entry.


Future improvements

  • Multi-element sync — accept an array of elements and fit them all to the same computed target width in a single pass, so a stack of pull-quotes share identical tracking
  • Clamped overshoot mode — instead of converging to exactly targetWidth, allow the user to specify a minFill ratio (e.g. 0.98) so the algorithm stops as soon as the line reaches 98 % of the target, avoiding aggressive tracking on very short words
  • SSR hydration hint — accept a pre-computed axisValue prop that is applied immediately on mount before the first ResizeObserver fires, eliminating the brief unstyled state on first render
  • Canvas-based width measurement — use CanvasRenderingContext2D.measureText() as a non-layout measurement path to avoid forced reflow on every resize cycle, with BCR as the fallback for accuracy

Current version: 1.0.17

Releases

No releases published

Packages

 
 
 

Contributors