Skip to content

darkroomengineering/fitbox

Repository files navigation

fitbox

Reflow-free text-to-box fitting for React, built on @chenglou/pretext.


Why this exists

If you have ever used Fitty or written your own "shrink text to container" logic, you know the shape of it: pick a font-size, put the element in the DOM, read getBoundingClientRect(), compare to the container, adjust, read again. The loop terminates quickly — five to ten iterations — but every read forces a layout reflow. Drag a window with twenty fittable headings on the page and you are asking the browser's layout engine to recompute itself thousands of times a second.

Every library does it this way because, until recently, the browser was the only oracle that could tell you how text would lay out. That changed when Cheng Lou released Pretext, a text measurement and layout library that uses canvas.measureText() — which does not reflow — as ground truth. With per-glyph widths cached, measuring a wrapped paragraph is microseconds of arithmetic.

fitbox is what a text-fitting library looks like when you build it on that primitive.

The math

Once measurement stops touching layout, the algorithm collapses.

Single-line fit is a closed form. Text width scales linearly with font-size. Prepare the text once at 1px and call its natural width w₁. For any container of width W:

fontSize = W / w₁

One division. No search. No DOM.

Multi-line fit is a reflow-free binary search. Line breaks are non-linear in (fontSize, maxWidth), so we search — but there is a scaling invariant: fontSize = s at maxWidth = W wraps identically to fontSize = 1 at maxWidth = W / s. So we prepare once at 1px and binary-search s by querying Pretext's measureLineStats(handle, W / s). Ten iterations to pixel precision, still pure arithmetic.

Static fluid CSS. Because single-line fit is linear in viewport width, the entire responsive curve is expressible as clamp(min, calc(a + b·vw), max) — a string the browser interpolates for free, zero runtime JavaScript. Fitty cannot produce this; it cannot know the fit without measuring the DOM. fitbox computes the clamp at build or load time and ships it inline.

SSR. Pretext needs a canvas, not a DOM. Give it @napi-rs/canvas on the server, compute fits in a loader, serialize the result as a preset, hydrate with the correct font-size already rendered. No layout shift, ever.

What fitbox is and isn't

Fitty fitbox
Measurement getBoundingClientRect() per probe canvas.measureText(), cached
Single-line fit Binary search over DOM W / w₁
Multi-line fit Reflow-free binary search
Fluid CSS Hand-rolled clamp Computed clamp(…)
SSR Supported via canvas polyfill
Bundle ~4KB min+gz ~1.3KB core / ~1.6KB react / ~1.7KB server (min+gz, each entry standalone)

fitbox is narrower than Fitty in one way — it ships a React adapter, not a plain-DOM binding — and wider in several others. Reach for Fitty if you need plain DOM or are supporting very old browsers. Reach for hand-rolled CSS fluid-typography recipes if you are comfortable guessing at your text's natural width. Reach for fitbox when you want the fit to be exact, to work under SSR, or to disappear into a static CSS string after the first render.

Beyond the DOM

Because measurement is reflow-free, nothing about the fit algorithm depends on the text ending up in an HTML element. layoutFit returns the per-line layout in a rendering-backend-agnostic shape:

import { prepare, layoutFit } from '@darkroomengineering/fitbox';

const handle = prepare('Hello world', 'Inter');
const { fontSize, lines } = layoutFit(handle, { width: 1024, maxLines: 2 });
// lines: Array<{ text: string; width: number; y: number }>

Those numbers feed directly into a WebGL/WebGPU text renderer (troika-three-text, drei's <Text>, a custom SDF shader), an offscreen Canvas, an SVG generator, a PDF pipeline — anywhere you want typography with correct fit and no DOM.


Install

bun add @darkroomengineering/fitbox

useFit — drop a ref on any element

import { useFit } from '@darkroomengineering/fitbox/react';

<h1 ref={useFit()}>Hello</h1>
<p ref={useFit({ maxLines: 3, maxSize: 48 })}>{text}</p>

That's the whole API for the common case. The hook reads textContent and the element's computed font-family/font-weight/font-style, runs prepare once, then mutates element.style.fontSize directly — no React re-render per resize frame, no style prop to merge.

  • A ResizeObserver refits on container resize.
  • A MutationObserver refits when the text changes (so <p ref={useFit()}>{dynamic}</p> works).
  • document.fonts.ready gates first measurement.
  • Requires React 19+ (uses the callback-ref cleanup pattern).

<FitText> — component sugar

For the cases where a component wrapper is tidier:

import { FitText } from '@darkroomengineering/fitbox/react';

<FitText maxLines={3} as="h1">
  Typography that actually fits its container.
</FitText>

Internally uses useFit. Also accepts fluid={…} for static-clamp CSS and preset={…} for SSR-shipped initial sizes.

useFitText — explicit text + React-styled

Escape hatch when you want React to own the styling (CSS-in-JS composition, or you need the full FitResult for downstream logic):

import { useFitText } from '@darkroomengineering/fitbox/react';

const { ref, style, result } = useFitText<HTMLHeadingElement>(text, {
  maxLines: 2,
  maxSize: 120,
});
return <h1 ref={ref} style={style}>{text}</h1>;

Fluid CSS — no JS at runtime

For responsive single-line headings, emit a static clamp() and let the browser interpolate:

import { prepare, fluidFit } from '@darkroomengineering/fitbox';
import { FitText } from '@darkroomengineering/fitbox/react';

const fluid = fluidFit(prepare('Fitbox', 'Inter'), {
  minViewport: 320,
  maxViewport: 1440,
  minSize: 24,
  maxSize: 180,
});
// fluid.cssClamp === 'clamp(120.755px, calc(103.827px + 5.29vw), 180px)'
// (bounds are derived: sMin/sMax = clamp(viewport / naturalWidth, minSize, maxSize),
//  so for short text the floor often lands above your minSize — here 'Fitbox' is wide
//  enough at 320px viewport that the lower bound is 120.755px, not 24px.)

<FitText fluid={fluid}>Fitbox</FitText>

For wrapping text, fluidFitMultiLine probes the viewport range, finds breakpoints where line count changes, and emits a stylesheet of media-query-scoped clamps.

SSR

// entry.server.ts
import { createCanvas } from '@napi-rs/canvas';
import { configureServerCanvas } from '@darkroomengineering/fitbox/server';

configureServerCanvas(() => createCanvas(1, 1), { cacheMax: 1024 });
// routes/home.ts — react-router loader
import { fitCached } from '@darkroomengineering/fitbox/server';

export async function loader() {
  return {
    title: fitCached('Hello', 'Inter', { width: 1200, maxLines: 1 }),
  };
}
// routes/home.tsx
import { FitText } from '@darkroomengineering/fitbox/react';
import { useLoaderData } from 'react-router';

export default function Home() {
  const { title } = useLoaderData();
  return <FitText preset={title}>Hello</FitText>;
}

fitCached / fluidFitCached / fluidFitMultiLineCached memoize in bounded LRUs so repeated calls (nav labels, common strings) don't re-measure. The multi-line variant is the most expensive call — it runs ~33 binary-search fits per invocation — so caching matters most there.

API

@darkroomengineering/fitbox

  • prepare(text, fontFamily, options?) — build a 1px Pretext handle.
  • fit(handle, { width, height?, maxLines?, minSize?, maxSize?, lineHeight? }) — closed-form single-line or binary-search multi-line.
  • layoutFit(handle, fitOpts) — same as fit, plus lines: Array<{ text, width, y }> for non-DOM renderers (WebGL, WebGPU, Canvas, SVG).
  • fluidFit(handle, { minViewport, maxViewport, widthFraction?, minSize?, maxSize? }) — single-line CSS clamp.
  • fluidFitMultiLine(handle, { …, maxLines, samples?, selector? }) — piecewise @media stylesheet for wrapping text.

@darkroomengineering/fitbox/react

  • useFit(options?) — callback ref. Fits textContent to container, mutates style.fontSize directly. The primary one-liner.
  • useFitText<E>(text, options) — returns { ref, style, result }. Escape hatch when you want React to own styling or need the raw FitResult.
  • <FitText> — element wrapper. Accepts as, preset, fluid.

@darkroomengineering/fitbox/server

  • configureServerCanvas(factory, options?) — install canvas shim, configure cache.
  • fitCached(text, family, fitOpts, prepareOpts?) — cached prepare + fit.
  • fluidFitCached(text, family, fluidOpts, prepareOpts?) — cached prepare + fluidFit.
  • fluidFitMultiLineCached(text, family, fluidOpts, prepareOpts?) — cached prepare + fluidFitMultiLine.
  • clearServerCache().

Caveats

  • Pretext uses canvas.measureText() as ground truth. On SSR you need @napi-rs/canvas or similar and configureServerCanvas() called once at startup.
  • fluidFitMultiLine interpolates linearly within stable-line-count segments; wrapping shifts inside a segment cause minor imprecision. Increase samples to narrow.
  • The server cache is an LRU by JSON.stringify of inputs — fine for curated strings, not suited for unbounded user content without the cacheMax cap.

Acknowledgments

  • Rik Schennink — Fitty, the canonical fit-text-to-box library and the shape of this problem.
  • Cheng Lou — Pretext, the reflow-free measurement primitive this is built on.

License

MIT — darkroom.engineering

About

Reflow-free text-to-box fitting for React, built on Pretext.

Resources

License

Stars

Watchers

Forks

Contributors