Fit Text
One line of text scaled until it exactly fills the width it was given — headline, KPI number or cover title, sized from a real browser measurement instead of an estimate.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/fit-text.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FitText" component (one ResizeObserver,
one Range for text metrics, the FontFaceSet API, plus the shared cn()
class-merge helper — no other dependencies).
Contract
- Named export `FitText`, forwardRef<HTMLElement>, props extend native HTML
attributes minus `children`:
- `children: string` — the line to scale. Typed as a string on purpose: the
size is solved by measuring rendered text, and arbitrary nodes have no
single advance width. Callers compose with a template literal.
- `as?: "div" | "span" | "p" | "h1".."h6"` — default `"div"`. The measured
box *is* this element; a heading may only contain phrasing content, so
wrapping a div in an h1 would be invalid markup.
- `minSize?: number` — default 12. `maxSize?: number` — default 160.
- `defaultSize?: number` — default 48; the size used for the server frame and
the matching hydration render.
- `overflow?: "clip" | "visible"` — default `"clip"`.
- `animate?: boolean` — default false.
- `onFitChange?: (fit: { fontSize, containerWidth, clamped }) => void`.
- Also export the `FitTextFit` result type and a `FitTextClamp =
"min" | "max" | null` union.
Scope, stated up front rather than hedged
- One line, width only. The text never wraps (`whitespace-nowrap`) and the
box's height is never an input. That is the design, not a shortcut: font size
changes the height, so anything that measured height would feed its own output
back into its own input. Filling a *box* — wrapping across lines until the
height is used up — is a different problem; say so instead of half-doing it.
Behavior
- Render `<Tag class="block w-full min-w-0">` around
`<span class="block whitespace-nowrap" style={{fontSize}}>`. The span is a
full-width block, so the container's width comes from the parent and is never
derived from the text.
- Measure by writing a candidate size onto the live span and reading a `Range`
over its contents (`range.selectNodeContents(node)` then
`getBoundingClientRect().width`). Do NOT estimate: per-character width tables
are off by tens of percent in both directions on real strings, and even a
canvas `measureText` model has to re-derive letter-spacing, kerning and
font-feature settings from computed styles — `tracking-tight` is em-based and
therefore changes with every candidate. Read a Range, not the span's own rect:
the span is full width, so its rect is the container's width, never the line's.
- Solver: measure at `maxSize` first (fits -> done, `clamped: "max"`), then at
`minSize` (still overflows -> done, `clamped: "min"`). Otherwise the bracket
is known good/bad, and the search is false position — scale the candidate by
`available / measured`, since advance width is very nearly proportional to
size — falling back to bisection whenever the next candidate would leave the
bracket or stall on its edge. Cap the loop (16 iterations is generous; a
typical solve costs four browser measurements in total) and only ever move the
lower bound to a size that measured as fitting, so the answer can never
overflow and the loop can never hang.
- Run the first solve synchronously inside a layout effect (`useLayoutEffect` on
the client, `useEffect` on the server via a `typeof window` switch), so the
default size is never painted after hydration.
- Re-fit on three triggers, and nothing else: a ResizeObserver on the root, a
`document.fonts.ready` promise, and a `document.fonts` `loadingdone`
listener. Re-create all of it when `children` changes — swapping the text does
not resize the box, so the observer would otherwise never fire again and the
old size would stay applied to a completely different string.
- Breaking the observer loop is the whole problem, so break it with the right
question. Changing the size changes the box's height, which re-notifies the
observer, which would solve again forever. A numeric "did the width move by
more than N px" guard cannot be tuned: too small and a shrink-to-fit parent —
whose width *is* the text's width — ratchets down forever, because every solve
moves the container it is measuring (measured with a 0.25px guard: 0.04px of
font per frame, no end); too large and it swallows a real sub-pixel settle
(measured with a 0.5px guard: a container animating to 260px settled 0.4px
past the last width the solver saw, and the line rendered 260.14px — outside
its box). Instead, remember the width of the line the last solve produced and
skip the re-solve only while that line still fits AND leaves less than ~0.6px
of slack. An echo and a self-referential parent both leave a line that already
fits; a real resize does not.
- Suppress transitions around the probes: `style.setProperty("transition",
"none", "important")` before measuring and restore afterwards. A live
transition on font-size makes every probe read an interpolated value, and the
solver then answers a question nobody asked — measured with a blanket
`transition: all 300ms` on the text, it picked maxSize for a 480px box and
rendered 3091px of text (644% of the container), then picked minSize for a
260px box and rendered 73px (28%). Use `important`, because a plain inline
declaration loses to an `!important` author rule. Do this even when `animate`
is off: the transition can come from the host app's stylesheet.
- With `animate`, restore the pre-solve size, flush it while transitions are
still suppressed, restore the transition, then set the target — so the size
eases once from old to new instead of chasing every probe. Skip that dance on
the very first solve so nothing animates in from the SSR default.
- Zero width (display:none, a detached subtree, a parent not laid out yet) is a
bail-out, not a retry: return without scheduling anything. The observer fires
on its own the moment the box gets a width. A `requestAnimationFrame` retry
instead burns ~240 frames every two seconds for as long as the element stays
hidden, and never measures anything.
- An empty or whitespace-only string is also a bail-out. "How large can nothing
be before it overflows" answers maxSize, which would blow the box up to its
tallest possible line while a value is still loading, then snap back.
- Clamp behaviour is explicit, and reflected as `data-clamped="min" | "max"` on
the root plus the `clamped` field of `onFitChange`. At `minSize` the line
cannot fit, so with `overflow: "clip"` the span gets `overflow-hidden
text-ellipsis` and ends in an ellipsis; with `"visible"` it runs past the
edge. At `maxSize` the line simply stops growing.
- Only clip while there is something to hide: before the first measurement (the
server frame, or a page whose JS has not run) and in the min-clamped state.
Dropping the clip once a size is solved means a solver bug shows up as visible
overflow instead of being silently swallowed, and `overflow:hidden` never cuts
the ascenders of a `leading-none` heading. Measured: the clip does not disturb
the measurement — a Range over a node with `overflow:hidden;
text-overflow:ellipsis` still reports the full 762.06px line.
- Sanitise the bounds: NaN / Infinity / non-positive values fall back to the
defaults, `maxSize` is raised to at least `minSize`, and `defaultSize` is
clamped into the bracket, so the server never emits a nonsense font size.
- Keep `onFitChange` in a latest-ref so an inline arrow function does not
re-create the observer on every render, and skip the callback entirely when a
re-solve lands on the same numbers.
- Cleanup: cancel the pending frame, disconnect the observer, remove the
`loadingdone` listener.
Rendering & styling
- Semantic tokens only; this component sets no colour at all — it inherits
everything from the caller's className (`font-semibold`, `tracking-tight`,
`tabular-nums`, `text-primary`), which is what keeps it drop-in.
- The only animation is the opt-in `transition-[font-size] duration-200
ease-out` plus `motion-reduce:transition-none`, so the reduced-motion path is
pure CSS with nothing to read during render.
- Accessibility: the text is real text in a real element, so it is selectable,
searchable and read normally. Use `as` to keep the heading level correct
rather than styling a div to look like a heading.
Customization levers
- `minSize` / `maxSize` — the whole editorial range. A KPI tile wants a wide
spread (18–96) so short and long values both look intentional; a hero title
wants a narrow one (32–72) so the design does not swing wildly between
breakpoints. `maxSize` is what stops a two-character value from becoming a
billboard.
- `defaultSize` — set it to roughly the size you expect at the most common
breakpoint; it is what a crawler, a print stylesheet and the pre-hydration
frame see.
- `as` — `h1`/`h2` for headings, `div` for numbers, `span` when the caller
already provides the block.
- `overflow` — `"visible"` when the design wants an oversized word to bleed past
a card edge; keep `"clip"` inside anything that must not scroll sideways.
- `animate` — on for panels that resize under the user's hand (drag handles,
collapsing sidebars); off for a static page where a resize should just land.
- `onFitChange` — drive a "showing at 24px" debug badge, tell a parent whether
the value had to be clamped so it can switch to an abbreviated form
("$4.18M" instead of "$4,182,904"), or feed an analytics counter.
- To fit a *box* instead of a width, the shape of the change is: give the
container a definite height, swap the Range measurement for the span's
`scrollHeight` with wrapping enabled, and keep the same bracket-then-search
loop. Do not also keep the height-driven ResizeObserver — measure the height
the parent gives you, never the height your own text produced.
- The parent must have a width that does not come from this text (a grid/flex
track, a fixed width, `width: 100%`). Inside a shrink-to-fit parent the
question is circular; the component still settles on one size there, but the
result is "whatever the default happened to produce", not a fit.Concepts
- Measured, not estimated — the candidate size is written onto the live element and the browser reports the advance width through a
Range. Hand-rolled width tables miss real strings by tens of percent, and every model of letter-spacing, kerning and font features has to be re-derived from computed styles; the rendered node already has all of it applied. - Bracket, then false position — one measurement at
maxSizeand one atminSizeprove which side the answer is on, then each step scales the candidate byavailable / measuredbecause advance width is nearly proportional to font size. Bisection is the fallback, the loop is capped, and the lower bound only ever moves to a size that measured as fitting — so the result can neither overflow nor hang. - The re-fit question, not a width threshold — changing the size changes the box's height, which re-notifies the ResizeObserver. Asking "did the width move by more than N px" cannot be tuned: small thresholds make a shrink-to-fit parent ratchet forever, large ones swallow a real sub-pixel settle and leave the line outside its box. Asking "does the line on screen still fit and still fill?" answers both cases exactly.
- Transitions are measurement poison — while
font-sizeis transitioning, every probe reads an interpolated value. Suppressing the transition (withimportant, so an author!importantrule cannot win) for the duration of the probes is what keeps the solver honest in an app that ships a blankettransition: all. - Font swap invalidates the answer — a web font replaces the fallback face without resizing anything, so nothing would notify the component.
fonts.readycovers the faces loading at mount and aloadingdonelistener covers anything that starts later. - Zero width is a bail-out, not a retry — a hidden or unmounted-looking box has no budget to solve against, and the ResizeObserver already fires the moment it gets one. Retrying on the next frame instead just burns frames for as long as the element stays hidden.
- Declared clamps —
minSizeandmaxSizeare hard stops with named consequences: at the bottom the line ellipsizes and reportsdata-clamped="min", at the top it stops growing and reports"max". A caller can react (switch to an abbreviated value) instead of discovering the clamp visually.
Search Highlight
Marks the parts of a text that match a search query — substring, per-word or fuzzy, with escaped queries and a current-match cursor.
Balanced Heading
A heading that breaks its lines to even lengths instead of stranding one word on the last line — pure CSS text-wrap with a copy-safe fallback for engines that lack it.