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