{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fit-text",
  "type": "registry:ui",
  "title": "Fit Text",
  "description": "A headline that sizes itself to exactly fill its container's width — per line, with any font, without a first-paint jump.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/fit-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface FitTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The headline. In `multiline` mode every \"\\n\" starts a new, independently fitted line. */\n  children: string;\n  /** Element to render. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\" | \"div\";\n  /** Smallest font size in px. Lines that still overflow at this size are allowed to wrap. */\n  min?: number;\n  /** Largest font size in px. Short lines stop growing here. */\n  max?: number;\n  /** Split on \"\\n\" and fit each line to the width on its own. */\n  multiline?: boolean;\n  /** Ease font-size changes when the container resizes. */\n  animate?: boolean;\n  /** Length of the resize transition in ms. */\n  duration?: number;\n  /** Unitless line height. */\n  lineHeight?: number;\n  /** Show blueprint guides: a hairline under each line with its fitted size. */\n  guides?: boolean;\n  /** Guide colour (any CSS colour). */\n  accent?: string;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** Average advance of a glyph relative to the font size; only used for the pre-measurement clamp. */\nconst GLYPH_ADVANCE = 0.58;\n\n/**\n * A headline that sizes itself to exactly fill its container's width.\n *\n * First paint uses a CSS clamp() estimate in container query units, so server HTML is already\n * close to the final size and nothing jumps. After mount each line is measured once at its\n * current size (text width scales linearly with font size), the exact size is written to a CSS\n * variable, and a ResizeObserver plus `document.fonts.ready` keep it exact afterwards.\n */\nexport function FitText({\n  children,\n  as = \"h2\",\n  min = 16,\n  max = 160,\n  multiline = true,\n  animate = true,\n  duration = 300,\n  lineHeight = 1,\n  guides = false,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: FitTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const low = Math.max(1, Math.min(min, max));\n  const high = Math.max(low, max);\n\n  const lines = multiline\n    ? children.split(\"\\n\").map((l) => l.trim()).filter(Boolean)\n    : [children.replace(/\\s*\\n\\s*/g, \" \").trim()];\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    let frame = 0;\n    let first = true;\n\n    const fit = () => {\n      frame = 0;\n      const lineEls = root.querySelectorAll<HTMLElement>(\"[data-ft-line]\");\n      lineEls.forEach((line) => {\n        const probe = line.querySelector<HTMLElement>(\"[data-ft-probe]\");\n        if (!probe) return;\n        // Both widths come from getBoundingClientRect, so an ancestor transform (e.g. a scale-in) cancels out.\n        const available = line.getBoundingClientRect().width;\n        if (!available) return;\n        // Measure on an invisible probe (never mid-transition). Width is nearly proportional to\n        // font size, so a few ratio steps converge; the last step guarantees no overflow.\n        const widthAt = (px: number) => {\n          probe.style.fontSize = `${px}px`;\n          return probe.getBoundingClientRect().width;\n        };\n        let ideal = 100;\n        for (let i = 0; i < 4; i++) {\n          const width = widthAt(ideal);\n          if (!width) return;\n          const next = (ideal * available) / width;\n          if (Math.abs(next - ideal) < 0.01 && width <= available) break;\n          ideal = width > available ? Math.min(next, ideal - 0.01) : next;\n        }\n        for (let guard = 0; guard < 40 && ideal > low && widthAt(ideal) > available; guard++) ideal -= 0.25;\n        const size = Math.floor(Math.min(high, Math.max(low, ideal)) * 100) / 100;\n        line.style.setProperty(\"--ft-size\", `${size}px`);\n        // Below the minimum the line cannot fit on one row: let it wrap instead of overflowing.\n        line.toggleAttribute(\"data-ft-wrap\", ideal < low);\n        const label = line.querySelector<HTMLElement>(\"[data-ft-label]\");\n        if (label) label.textContent = `${size.toFixed(1)}px${ideal > high ? \" · max\" : ideal < low ? \" · min\" : \"\"}`;\n      });\n      if (first) {\n        first = false;\n        // Only enable the transition after the first exact fit, so the initial refinement never animates.\n        requestAnimationFrame(() =>\n          root.style.setProperty(\"--ft-duration\", animate && !reduceMotion ? `${Math.max(0, duration)}ms` : \"0ms\"),\n        );\n      }\n    };\n    const schedule = () => {\n      if (!frame) frame = requestAnimationFrame(fit);\n    };\n\n    // Only width matters; the height changes we cause ourselves are ignored.\n    let lastWidth = -1;\n    const observer = new ResizeObserver((entries) => {\n      const width = Math.round(entries[0]?.contentRect.width ?? 0);\n      if (width === lastWidth) return;\n      lastWidth = width;\n      schedule();\n    });\n    observer.observe(root);\n    // Web fonts change glyph widths: refit once they have loaded (including fonts requested later).\n    const fonts = document.fonts;\n    fonts?.ready.then(schedule).catch(() => {});\n    fonts?.addEventListener?.(\"loadingdone\", schedule);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      fonts?.removeEventListener?.(\"loadingdone\", schedule);\n    };\n  }, [children, multiline, low, high, animate, duration, reduceMotion, guides, lineHeight]);\n\n  const Tag = as as React.ElementType;\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={`block w-full min-w-0 text-zinc-950 [container-type:inline-size] dark:text-zinc-50 ${className}`}\n      style={{ [\"--ft-accent\" as string]: accent, lineHeight, ...style }}\n      {...props}\n    >\n      {lines.map((line, i) => {\n        // Estimate in container query units: close to the measured result for typical fonts.\n        const estimate = Math.round((100 / Math.max(1, Array.from(line).length * GLYPH_ADVANCE)) * 100) / 100;\n        return (\n          <React.Fragment key={i}>\n            {i > 0 ? \" \" : null}\n            <span\n              data-ft-line=\"\"\n              className=\"relative block whitespace-nowrap transition-[font-size] duration-[var(--ft-duration,0ms)] ease-[cubic-bezier(0.22,1,0.36,1)] data-[ft-wrap]:whitespace-normal\"\n              style={{ fontSize: `var(--ft-size, clamp(${low}px, ${estimate}cqi, ${high}px))` }}\n            >\n              <span className=\"inline-block\">{line}</span>\n              <span data-ft-probe=\"\" aria-hidden=\"true\" className=\"invisible absolute left-0 top-0 w-max whitespace-nowrap\">\n                {line}\n              </span>\n              {guides ? (\n                <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 bottom-0 block h-0 border-b border-[var(--ft-accent)]/60\">\n                  <span className=\"absolute -top-1 left-0 h-2 w-px bg-[var(--ft-accent)]\" />\n                  <span className=\"absolute -top-1 right-0 h-2 w-px bg-[var(--ft-accent)]\" />\n                  <span\n                    data-ft-label=\"\"\n                    className=\"absolute right-1 top-1 font-mono text-[10px] font-normal uppercase leading-none tracking-[0.14em] text-[var(--ft-accent)]\"\n                  />\n                </span>\n              ) : null}\n            </span>\n          </React.Fragment>\n        );\n      })}\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/fit-text"
}
