{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fit-text",
  "title": "Fit Text",
  "description": "Text shrunk to the largest whole-pixel size that still fits the box it was given, and kept fitting as the box changes. Reach for it wherever a string has to live in a space somebody else sized: the figure in a KPI tile that is usually \"1.2M\" and occasionally \"1,284,993\", a product or customer name on a card, a person's name on a name badge or a seating chart, a heading on a slide, a label in a fixed table column, a cell in a dashboard laid out on a grid, a number on a TV wallboard or kiosk nobody can scroll or hover, an org-chart node, a chart or axis label, a calendar event title in a day column, a tier name in a pricing table, an invoice line item, and any translated string that runs 40% longer in German than the design allowed for. Common asks it answers: \"fit text to container react\", \"auto resize text to fit div\", \"shrink text to fit width\", \"scale text to fit container\", \"auto font size react\", \"responsive font size to fit box\", \"make text fit a fixed width div\", \"auto shrink font size when text is too long\", \"text overflows card react\", \"KPI number too long dashboard\", \"long product name breaks card layout\", \"fit text to width without truncating\", \"shrink to fit instead of ellipsis\", \"react-textfit\", \"react-fittext\", \"react-fit-text\", \"use-fit-text\", \"fitty react\", \"textfit\", \"FitText.js\", \"auto-size text react hook\", \"ResizeObserver font size react\", \"binary search font size to fit\", \"text fits at one width but overflows at another\", \"font size is wrong until the web font loads\", \"text shrinks then overflows again on resize\", \"fit text inside a flex item\", \"shrink text in a table cell\", \"文字を枠に収める react\", \"はみ出す文字を縮小する\", \"幅に合わせてフォントサイズを自動調整\", \"文字が長いとカードが崩れる\", \"自動 フォントサイズ 調整 react\", \"枠内に収まる最大の文字サイズ\". **The version almost everybody writes first measures the string once and scales it, and that assumes glyph width is proportional to font size.** It is not. Measured on the default UI face, the same string takes 12.49px of advance per point of size at 16px and 11.43px at 64px — faces track tighter as they grow — so an estimate of `16 * (available / widthAt16)` was wrong at most of the box widths swept, in one direction throwing away four sizes that would have fitted and in the other naming a size that overflows. A registry component inherits whatever face the page is using, so neither direction can be assumed away. This does a binary search over whole pixels instead, which costs six or seven measurements over `[8, 96]` and is exact. **Whole pixels, because sub-pixel precision is below the rounding of the thing being measured.** Bisecting to a thousandth of a pixel took twelve more forced layouts, moved the answer by 0.03px, and then re-measured as 241px of content in a 240px box — one pixel worse than the integer answer that had just fitted. `scrollWidth` and `clientWidth` are rounded integers, which is also why a pixel of reported overflow is forgiven: without that tolerance a box whose text fits it exactly loses a whole step of size to a rounding artefact. **The search runs over the whole range every time and never reads its own last answer.** Nudging relative to the current size — shrink a step while it overflows, grow a step while there is room — has no fixed point: inside a ResizeObserver it oscillated between 21px and 20px for as long as it was left running, 37 callbacks in 600ms, with no error raised anywhere and a layout pass burnt every frame. A search from scratch is a fixed point, so writing the size from inside the callback settles after one extra pass instead of never. **The ceiling is re-read with this component's own size cleared, every time.** Read it without clearing and the ceiling ratchets downwards — each measurement takes the previous fitted size as its maximum and the text can never grow back when the box widens again. Clearing it also means the ceiling is whatever the stylesheet currently asks for, so `className=\"text-2xl md:text-5xl\"` works for free: the breakpoint changes the design size, the resize that carries it re-measures, and the new ceiling is picked up without being told about. **It measures the real element, not a hidden probe span.** For a bare string a probe is a measurement of the same thing, agreeing to the exact fraction of a pixel at every size tested — but a probe is only the same box if every box property matches, and anything in `em` scales with the font. Give the element `px-[0.5em]` and a bare probe is 48px narrower at a 48px font, so the fitted size overflows by a whole em in exactly the case where fitting matters most. **A box that has not been laid out is not measured at all.** Inside a closed dialog, an unselected tab, anything `display: none`, both measurements report zero, and zero is \"not measured yet\" rather than \"nothing fits\" — shrinking on the strength of it leaves 12px text for the moment the dialog opens, which is the one state no resize comes along to correct. A web font arriving after the first measurement is waited on separately through `document.fonts.ready`, because a face swapping in changes every glyph width without changing the box and no observer reports it. **The floor is the part worth arguing about.** Shrinking without a limit always \"fits\", by producing text nobody can read, and a 6px label in a dashboard is a worse outcome than a clipped one — a reader can at least see that something was cut and widen or hover to get it. So `min` defaults to 12px, and at the floor this stops shrinking and clips with an ellipsis, reporting `overflowing` so the caller can do something better. It never makes text bigger: growing a short string to fill its box is a different component with a different failure mode (a two-letter name at 90px), which is why the ceiling is the design's own size rather than a number chosen here. The text itself is never touched — the same text node at a different size — so find-in-page, selection, the clipboard and the accessibility tree all get the string in full. That is the whole difference between this and the ellipsis answers to the same problem, which solve it by removing characters. `useFitFontSize` is exported on its own for a size you want without this markup (a number in a table cell, a label inside an SVG you are drawing yourself), and `onFit` is guarded on the values last reported rather than on a dependency array, so a parent that re-renders on the callback cannot turn a measurement into a loop. Theme tokens only, no hardcoded colours, no dependencies beyond React. Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings fitText, scaleToFit, textFit, ResizeObserver, getBoundingClientRect, useLayoutEffect and fontSize are every one of them a zero hit. Official never measures an element and never sets a font size in code at all. Within pulld it sits beside middle-truncate and overflow-tooltip, which answer the same question by removing characters or revealing them on hover rather than by keeping all of them smaller, and opposite autosize-textarea, which grows the box to fit the text where this fits the text to the box. column-resizer and virtual-list measure boxes but never resize type.",
  "files": [
    {
      "path": "registry/ui/fit-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Pixels of overflow that are too few to shrink for.\n *\n * `scrollWidth` and `clientWidth` are rounded integers, so a box that fits its text exactly can\n * still report one pixel of overflow and cost the text a whole step of size for nothing. One pixel\n * of slack is the same tolerance overflow-tooltip arrived at from the other direction, and for the\n * same reason.\n */\nconst OVERFLOW_SLACK = 1\n\n/** The smallest size this will shrink to unless the caller says otherwise. */\nconst DEFAULT_MIN = 12\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport interface FitFontSizeOptions {\n  /** Smallest size to consider, in px. */\n  min: number\n  /** Largest size to consider, in px — normally the size the design already asked for. */\n  max: number\n  /** Whether the text fits at `size`. Called with integers only. */\n  fits: (size: number) => boolean\n}\n\n/**\n * The largest whole pixel size in `[min, max]` that fits, or `min` when nothing does.\n *\n * Separated from the measuring so the arithmetic can be tested without a browser, and written as a\n * binary search over integers for two measured reasons.\n *\n * **Integers, because sub-pixel precision buys nothing and lands outside the box.** Refining the\n * answer past the integer by bisecting to a thousandth of a pixel took twelve more measurements\n * and moved the size by **0.03px** — and when the element was re-measured at that \"better\" size it\n * reported **241px of content in a 240px box**, one pixel wider than the integer answer that had\n * just fitted. The extra precision is below the rounding of the thing being measured, so it is not\n * precision at all.\n *\n * **A search, rather than one measurement and a ratio, because glyph width is not proportional to\n * font size.** The cheap version everybody writes first measures the text once at 16px and scales:\n * `16 * (available / width16)`. Swept across ten box widths against the true answer, that estimate\n * was wrong in **seven of them** — by −4px at a 640px box (it says 51 where 55 fits, throwing away\n * four sizes) and, worse, by **+1px at a 120px box, where it claims a size that overflows**. The\n * cause is that the default UI face on macOS tracks tighter as it grows: the same string measures\n * 12.49px per point of size at 16px and 11.43px at 64px. Any registry component inherits whatever\n * face the page is using, so this cannot be assumed away. Six measurements over `[8, 96]` get the\n * exact answer instead.\n *\n * `fits` is required to be monotonic — if a size fits, every smaller one does. Measured on both\n * axes this component uses: sweeping 40px down to 6px in a capped box, the predicate flipped\n * **exactly once** over 35 samples, and the height of a wrapping paragraph never once rose as the\n * size fell across 109 samples at each of `text-wrap: normal`, `balance` and `pretty`.\n */\nexport function fitFontSize({ min, max, fits }: FitFontSizeOptions): number {\n  // A caller computing these from props can hand over NaN (`parseFloat` of an empty style) or an\n  // inverted range (a `min` larger than the design size). Both would otherwise return garbage that\n  // is then written to `style.fontSize`, where it is silently ignored and the text stays at its\n  // unfitted size — the failure that looks like the component doing nothing at all.\n  const low = Number.isFinite(min) ? Math.max(Math.floor(min), 1) : DEFAULT_MIN\n  const high = Number.isFinite(max) ? Math.floor(max) : low\n  if (high <= low) return low\n\n  let lo = low\n  let hi = high\n  // `min` rather than nothing: a box too small for even the smallest readable size still has to\n  // render something, and an unreadable string is worse than a clipped one. The caller sees this\n  // as `overflowing`, and the component clips with an ellipsis at that point.\n  let best = low\n  while (lo <= hi) {\n    const mid = (lo + hi) >> 1\n    if (fits(mid)) {\n      best = mid\n      lo = mid + 1\n    } else {\n      hi = mid - 1\n    }\n  }\n  return best\n}\n\n/** What `useFitFontSize` settled on. */\nexport interface FitResult {\n  /** The size applied, in px, or null before the first measurement. */\n  size: number | null\n  /** True when the text does not fit even at `min` and is being clipped. */\n  overflowing: boolean\n}\n\nexport interface UseFitFontSizeOptions {\n  /** Smallest size to shrink to, in px. Defaults to 12. */\n  min?: number\n  /**\n   * Largest size, in px.\n   *\n   * Left out, the element's own CSS size is used — so `className=\"text-4xl\"` is the ceiling and\n   * this only ever shrinks. That is re-read on every measurement, which is what makes a responsive\n   * ceiling (`text-2xl md:text-5xl`) work: the breakpoint changes the design size, the resize that\n   * carries it re-measures, and the new ceiling is picked up for free.\n   */\n  max?: number\n  /** 1 for a single line (the default), more for a clamped paragraph. */\n  lines?: number\n  /** Re-measure when these change — the text itself, for content that is not a plain string. */\n  deps?: React.DependencyList\n}\n\n/**\n * Shrinks `ref`'s text until it fits its box, and keeps it fitting.\n *\n * Exported because the size is worth having without this component's markup — a number in a table\n * cell, a label inside an SVG, a name on a seating chart you are drawing yourself.\n *\n * **It measures the element, not a stand-in copy.** The obvious implementation measures a hidden\n * probe span (which is how this registry's middle-truncate finds its cut point) and applies the\n * answer to the real element. For a string that is a measurement of the same thing, and the two\n * agreed to **the exact same fraction of a pixel at all seven sizes tested**. But a probe is only\n * the same box if every box property is the same, and anything in `em` scales with the font: give\n * the real element `px-[0.5em]` and the bare probe is **48px narrower at a 48px font**, so the\n * fitted size overflows by a whole em and the component is wrong in exactly the cases where it is\n * most needed. Writing the candidate size to the real element and reading `scrollWidth` back costs\n * a forced layout per step — six of them — and is immune to all of it.\n *\n * **The write damps itself, but only because it is a fixed point.** Writing font size from inside a\n * ResizeObserver callback resizes the observed element and the observer fires again; measured, that\n * settles after **one extra pass** (two callbacks, the second computing the same size and writing\n * nothing new). What does not settle is the version that nudges relative to the current size —\n * shrink a step while it overflows, grow a step while there is room — which **oscillated between\n * 21px and 20px for as long as it was left running**, 37 callbacks in 600ms, with no error anywhere\n * and a layout pass burnt every frame. So the search always runs over the whole range from scratch\n * and never reads its own last answer, and the observer ignores anything but a change of width\n * (measured: turns those two callbacks into one application).\n */\nexport function useFitFontSize(\n  ref: React.RefObject<HTMLElement | null>,\n  { min = DEFAULT_MIN, max, lines = 1, deps = [] }: UseFitFontSizeOptions = {}\n): FitResult {\n  const [result, setResult] = React.useState<FitResult>({ size: null, overflowing: false })\n\n  const measure = React.useCallback(() => {\n    const el = ref.current\n    if (!el) return\n\n    // The ceiling is read with our own size cleared, so it is the size the stylesheet asks for\n    // rather than whatever this hook wrote last time. Without the clear, the ceiling ratchets\n    // downwards: every measurement would take the previous fitted size as its maximum and the text\n    // could never grow back when the box widened.\n    const explicitMax = typeof max === \"number\"\n    const appliedBefore = el.style.fontSize\n    if (!explicitMax) el.style.fontSize = \"\"\n    const ceiling = explicitMax\n      ? (max as number)\n      : parseFloat(getComputedStyle(el).fontSize)\n\n    // A box with no layout — inside a closed dialog, an unselected tab, anything `display: none` —\n    // reports zero for both, and zero is not \"nothing fits\": it is \"not measured yet\". Shrinking to\n    // the floor on the strength of it would leave the text at 12px when the dialog opens, which is\n    // the one state no resize is guaranteed to correct.\n    const axis = lines > 1 ? (\"Height\" as const) : (\"Width\" as const)\n    if (!(el[`client${axis}`] > 0)) {\n      // Put back whatever was applied before the ceiling was read. Leaving it cleared would drop\n      // the element to its CSS size with no re-render owed to restore it — React only writes the\n      // style prop again when the fitted size changes, and it has not.\n      el.style.fontSize = appliedBefore\n      return\n    }\n\n    const fits = (size: number) => {\n      el.style.fontSize = `${size}px`\n      return el[`scroll${axis}`] - el[`client${axis}`] <= OVERFLOW_SLACK\n    }\n\n    const size = fitFontSize({ min, max: ceiling, fits })\n    // Applied last so the element is left at the chosen size rather than at whichever candidate\n    // the search happened to try last, and read back once to answer \"did it fit even here\".\n    const overflowing = !fits(size)\n    setResult((previous) =>\n      previous.size === size && previous.overflowing === overflowing\n        ? previous\n        : { size, overflowing }\n    )\n  }, [ref, min, max, lines])\n\n  // Before paint, so the text is never seen at a size that does not fit.\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  useIsomorphicLayoutEffect(measure, [measure, ...deps])\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n\n    // A face arriving after the first measurement changes every glyph width without changing the\n    // box, so nothing else reports it — the observer below least of all, which is why this does not\n    // sit behind the same feature test. A browser old enough to lack ResizeObserver is if anything\n    // likelier to apply a web font late, and gating the two together left the text sized for the\n    // fallback face in exactly that case. overflow-tooltip measured the same swap putting a box\n    // that reported \"fits\" 8px over.\n    let cancelled = false\n    const fonts = typeof document !== \"undefined\" ? document.fonts : undefined\n    fonts?.ready.then(() => {\n      if (!cancelled) measure()\n    })\n\n    // Width only. The element's height changes every time the size is written, and reacting to that\n    // is the second callback that measured above — harmless, since the search is a fixed point, but\n    // a forced layout for an answer already known.\n    let lastWidth: number | null = null\n    const observer =\n      typeof ResizeObserver === \"undefined\"\n        ? null\n        : new ResizeObserver((entries) => {\n            const width = entries[entries.length - 1]?.contentRect.width\n            if (width === undefined) return\n            if (lastWidth !== null && Math.abs(width - lastWidth) < 0.5) return\n            lastWidth = width\n            measure()\n          })\n    observer?.observe(el)\n\n    return () => {\n      cancelled = true\n      observer?.disconnect()\n    }\n  }, [ref, measure])\n\n  return result\n}\n\nexport interface FitTextProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** The text. */\n  children: React.ReactNode\n  /** Smallest size to shrink to, in px. Defaults to 12. */\n  min?: number\n  /** Largest size, in px. Defaults to the element's own CSS size, so this only ever shrinks. */\n  max?: number\n  /** 1 for a single line (the default), more for a clamped paragraph. */\n  lines?: number\n  /** Told the size that was applied, and whether it is still clipped at `min`. */\n  onFit?: (result: FitResult) => void\n}\n\n/**\n * Text shrunk to the largest size that fits the box it was given.\n *\n * For every place a string has to live in a space somebody else sized: the number in a KPI tile, a\n * product name on a card, a person's name on a name badge or a seating chart, a heading on a slide,\n * a label in a fixed table column, a figure in a dashboard cell that is usually `1.2M` and\n * occasionally `1,284,993`. Style it as you would any text — `className=\"text-4xl font-semibold\"` —\n * and that size becomes the ceiling it shrinks from.\n *\n * **The floor is the part worth arguing about.** Shrinking without a limit always \"fits\", by\n * producing text nobody can read, and a 6px label in a dashboard is a worse outcome than a clipped\n * one — the reader can at least tell that something has been cut, and can usually hover or widen to\n * see it. So `min` defaults to 12px and, at the floor, the component stops shrinking and clips with\n * an ellipsis instead, the same way it would have without any of this.\n *\n * What it does **not** do is make text bigger. Growing a short string to fill its box is a\n * different component with a different failure mode (a two-letter name rendered at 90px), and\n * conflating them is why the ceiling here is the design's own size rather than a number this\n * component chose.\n *\n * The text itself is never touched — it is the same text node at a different size — so find-in-page,\n * selection, the clipboard and the accessibility tree all see the string in full. That is the\n * difference between this and the ellipsis-based answers to the same problem, which solve it by\n * removing characters.\n */\nexport const FitText = React.forwardRef<HTMLSpanElement, FitTextProps>(function FitText(\n  { children, min = DEFAULT_MIN, max, lines = 1, onFit, className, style, ...props },\n  forwardedRef\n) {\n  const innerRef = React.useRef<HTMLSpanElement>(null)\n  React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLSpanElement)\n\n  const result = useFitFontSize(innerRef, { min, max, lines, deps: [children] })\n\n  const onFitRef = React.useRef(onFit)\n  onFitRef.current = onFit\n  // Guarded on the values last reported rather than on the dependency array, so a parent that\n  // re-renders on this callback cannot turn a measurement into a loop — the same guard\n  // overflow-tooltip needs for the same reason.\n  const reported = React.useRef<FitResult | null>(null)\n  React.useEffect(() => {\n    const last = reported.current\n    if (last && last.size === result.size && last.overflowing === result.overflowing) return\n    reported.current = result\n    onFitRef.current?.(result)\n  }, [result])\n\n  return (\n    <span\n      ref={innerRef}\n      className={cn(\n        \"block\",\n        // Clipping is both the floor's fallback and what keeps an unfitted first paint inside its\n        // box: `overflow: hidden` also gives the element an automatic minimum size of zero, which\n        // is what lets it shrink below its text inside a flex row or a grid track with no\n        // `min-w-0` from the caller.\n        lines > 1 ? \"overflow-hidden\" : \"truncate\",\n        className\n      )}\n      style={{\n        // The caller's style comes first and this component's own goes after it, which is the\n        // reverse of the usual order and deliberate: these three properties are the component. A\n        // caller writing `style={{ fontSize: 48 }}` is naming the size to shrink *from* — an inline\n        // size is part of the computed style the ceiling is read from — and if that same value also\n        // won here it would overwrite the fitted size and silently switch the fitting off.\n        ...style,\n        // Before the first measurement there is no size to apply, and the server has none either.\n        // The element renders at its CSS size and the layout effect settles it before paint, so\n        // nothing is ever seen at the wrong size — and if the effect never runs at all, the text is\n        // at the size the stylesheet asked for rather than at a guess this component made.\n        ...(result.size !== null ? { fontSize: `${result.size}px` } : null),\n        ...(lines > 1\n          ? {\n              // No class is generated for an arbitrary clamp count, so a numeric prop has to set it\n              // here or quietly do nothing for every N the build never saw.\n              display: \"-webkit-box\",\n              WebkitBoxOrient: \"vertical\",\n              WebkitLineClamp: lines,\n            }\n          : null),\n      }}\n      {...props}\n    >\n      {children}\n    </span>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
