Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Elements this can render as. A heading may only contain phrasing content, so
 * the measured box has to *be* the heading rather than sit inside one.
 */
export type FitTextElement = "div" | "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"

export type FitTextClamp = "min" | "max" | null

export interface FitTextFit {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/fit-text.json

Prompt

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 maxSize and one at minSize prove which side the answer is on, then each step scales the candidate by available / measured because 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-size is transitioning, every probe reads an interpolated value. Suppressing the transition (with important, so an author !important rule cannot win) for the duration of the probes is what keeps the solver honest in an app that ships a blanket transition: all.
  • Font swap invalidates the answer — a web font replaces the fallback face without resizing anything, so nothing would notify the component. fonts.ready covers the faces loading at mount and a loadingdone listener 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 clampsminSize and maxSize are hard stops with named consequences: at the bottom the line ellipsizes and reports data-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.

On This Page