React FitText component

Date: 2026-05-01
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";

interface FitTextProps {
    text: string;
    className?: string;
    style?: CSSProperties;
    minScale?: number;
    maxScale?: number;
}

export function FitText({
    text,
    className,
    style,
    minScale = 0.1,
    maxScale = Number.POSITIVE_INFINITY,
}: FitTextProps) {
    const containerRef = useRef<HTMLDivElement | null>(null);
    const textRef = useRef<HTMLSpanElement | null>(null);
    const resizeFrameRef = useRef<number | null>(null);
    const [scale, setScale] = useState(1);

    const recalculate = useCallback(() => {
        const container = containerRef.current;
        const textElement = textRef.current;

        if (!container || !textElement) {
            return;
        }

        const containerWidth = container.clientWidth;
        const containerHeight = container.clientHeight;
        const textWidth = textElement.offsetWidth;
        const textHeight = textElement.offsetHeight;

        if (containerWidth <= 0 || containerHeight <= 0 || textWidth <= 0 || textHeight <= 0) {
            return;
        }

        const nextScale = Math.max(
            minScale,
            Math.min(maxScale, Math.min(containerWidth / textWidth, containerHeight / textHeight)),
        );

        if (!Number.isFinite(nextScale)) {
            return;
        }

        setScale((currentScale) => (Math.abs(currentScale - nextScale) < 0.001 ? currentScale : nextScale));
    }, [maxScale, minScale]);

    useLayoutEffect(() => {
        recalculate();
    }, [recalculate, text]);

    useEffect(() => {
        const container = containerRef.current;
        const textElement = textRef.current;

        if (!container) {
            return;
        }

        const handleResize = () => {
            if (resizeFrameRef.current !== null) {
                cancelAnimationFrame(resizeFrameRef.current);
            }

            // Batch resize work into the next animation frame to avoid layout thrashing.
            resizeFrameRef.current = requestAnimationFrame(recalculate);
        };

        const observer = new ResizeObserver(handleResize);
        observer.observe(container);

        if (textElement) {
            observer.observe(textElement);
        }

        recalculate();

        return () => {
            observer.disconnect();

            if (resizeFrameRef.current !== null) {
                cancelAnimationFrame(resizeFrameRef.current);
            }
        };
    }, [recalculate, text]);

    return (
        <div
            ref={containerRef}
            className={className}
            style={{
                position: "relative",
                width: "100%",
                height: "100%",
                overflow: "hidden",
                ...style,
            }}
        >
            <span
                ref={textRef}
                style={{
                    position: "absolute",
                    top: "50%",
                    left: "50%",
                    whiteSpace: "nowrap",
                    lineHeight: 1,
                    display: "inline-block",
                    transform: `translate(-50%, -50%) scale(${scale})`,
                    transformOrigin: "center center",
                    willChange: "transform",
                }}
            >
                {text}
            </span>
        </div>
    );
}

export default FitText;
101230cookie-checkReact FitText component