@liiift-studio/fit-flush
v1.0.2
Published
Binary-search font-size fitting — lock any text to its container width, height, or both, with variable-font axis safety
Readme
V1.1
fit-flush
Fit text to its container. Binary-search sizing with variable-font axis safety, for the rare case neither clamp() nor container-query units will do the job.
TypeScript · Zero runtime dependencies · React + Vanilla JS
The problem
CSS has no way to say "size this text exactly as large as possible without overflowing its container."
clamp()is viewport-linear, not container-aware.- Container-query units (
cqw,cqh) give coarse scaling, not precise text-fit. - Neither is aware of variable-font axis travel — text that fits today will overflow tomorrow when an axis animates to its max.
fit-flush solves all three: it measures the text off-screen, searches for the largest font-size that fits width and/or height, and — if you pass vfSettings — holds every axis at its max during measurement so the fit survives future axis animation.
Install
npm install @liiift-studio/fit-flushUsage
Next.js App Router: add
"use client"at the top of any file using the hook or component — fit-flush toucheswindowandResizeObserver.
React component
"use client"
import { FitFlushText } from "@liiift-studio/fit-flush"
export default function Hero() {
return (
<section style={{ width: "100%", height: "60vh" }}>
<FitFlushText as="h1" mode="both" max={320}>
Headline
</FitFlushText>
</section>
)
}React hook
"use client"
import { useFitFlush } from "@liiift-studio/fit-flush"
// Inside a React component:
export function Title() {
const ref = useFitFlush<HTMLHeadingElement>({ mode: "width" })
return <h1 ref={ref}>Resizing headline</h1>
}The hook re-runs on container resize (ResizeObserver, width-only dedup) and after web fonts load (document.fonts.ready). It cleans up on unmount.
Vanilla JS — one-shot
import { fitFlush } from "@liiift-studio/fit-flush"
const target = document.querySelector<HTMLElement>("h1")!
const size = fitFlush(target, { mode: "both", max: 240 })Vanilla JS — live handle
import { fitFlushLive } from "@liiift-studio/fit-flush"
const target = document.querySelector<HTMLElement>("h1")!
const handle = fitFlushLive(target, { mode: "both", max: 240 })
// Later — clean up:
// handle.dispose()fitFlushLive attaches a ResizeObserver to the container and re-fits after document.fonts.ready. Call handle.refit() to re-run manually after changing the text, and handle.dispose() to stop observing and restore the original fontSize.
Variable-font worst-case safety
If you animate variable-font axes elsewhere on the page, pass the full axis ranges so fit-flush measures at the worst case:
fitFlush(target, {
mode: "width",
vfSettings: {
wght: { min: 100, default: 400, max: 900 },
wdth: { min: 75, default: 100, max: 125 },
},
})TypeScript
import { fitFlush, type FitFlushOptions } from "@liiift-studio/fit-flush"
const options: FitFlushOptions = { mode: "both", min: 12, max: 320, precision: 0.25 }
const size: number = fitFlush(document.querySelector<HTMLElement>("h1")!, options)Options
| Option | Type | Default | Description |
|---|---|---|---|
| mode | 'width' \| 'height' \| 'both' | 'both' | Which container dimension(s) to fit. 'width' uses an analytical fast path (no-wrap single line). 'height' reflows normally. 'both' takes the stricter of the two. |
| min | number | 8 | Minimum font-size in px. |
| max | number | 400 | Maximum font-size in px. |
| precision | number | 0.5 | Binary-search convergence precision in px. |
| padding | number \| { x?, y? } | 0 | Inset from container edges in px. A single number insets both axes. |
| vfSettings | Record<string, { max: number }> | — | Variable-font axis ranges. When present, measurement runs at every axis' max for worst-case safety. |
| container | HTMLElement | target.parentElement | Override the container used for measurement. |
How it works
- Snapshot container — reads container dimensions in a single batch, subtracts
padding. - Clone probe — creates a
position: fixed; left: -99999px; visibility: hiddenmeasurement span, style-copied from the target viagetComputedStyle. The probe isaria-hiddenand appended todocument.body— never injected into the target's subtree, so there is zero visible layout disruption during measurement. - Apply max VF axis — if
vfSettingsis present, the probe'sfont-variation-settingsis set to the maximum of every axis before the search begins. - Search for size
mode: 'width'uses an analytical fast path: measure at 100 px, linearly predict the target size, verify in one write. Typically one or two measurements.mode: 'height'and'both'use binary search: ~10 iterations to converge over[8, 400]at0.5 pxprecision.
- Write — sets
target.style.fontSizeto the computed size and removes the probe. - Restore scroll — saves
window.scrollYbefore mutation and restores viarequestAnimationFrame(iOS Safari does not honouroverflow-anchor: none, so heightmutations can trigger scroll jumps).
Line break safety
For mode: 'height' and 'both', the probe is measured with the same inner width and white-space: normal as the target. Line breaks are whatever the browser produces at the fitted size — the tool never rewrites word breaks or injects spans into your live DOM.
SSR
fitFlush and fitFlushLive are SSR-safe. On the server, fitFlush returns 0 and fitFlushLive returns a no-op handle.
prefers-reduced-motion
fit-flush v0.0.1 is a one-shot size — no animation, nothing to honour. A future animated-transition mode will gate on prefers-reduced-motion.
Dev note — next in devDependencies
The root package.json lists next in devDependencies. This is intentional — Vercel inspects the root package.json to detect the framework for the site/ subdirectory deploy. Removing next causes Vercel to fall back to a static build and skip the Next.js pipeline.
Future improvements
- Animated transitions between target sizes on resize (gated by
prefers-reduced-motion) sharedoption — fit a group of elements to a common size for headline gridsonFitcallback fired after every recompute- Rich inline HTML preservation in the probe (currently text-only)
- Measurement caching — skip re-measurement when text, container size, and options are unchanged
