Skip to main content
Volver al blog
CSSContainer QueriesTypographyFrontendPerformance

Ajusta el texto a su contenedor con CSS puro — sin JavaScript

Todo el mundo escribe un hook de JavaScript para reducir el font-size hasta que el texto quepa en un contenedor. Aquí tienes una alternativa totalmente funcional y sin JS usando unidades de container query, clamp() y separación silábica según el idioma — incluida la trampa de la cascada que hace que la gente se rinda.

Publicado 24 de julio de 202611 min de lectura

Los titulares grandes tienen una costumbre molesta: el texto que encaja a la perfección en tu diseño se desborda en cuanto el contenedor se estrecha o la copia se alarga en otro idioma. Durante años la solución estándar ha sido JavaScript: medir el texto renderizado, compararlo con su contenedor y reducir la fuente hasta que quepa. Casi todos reinventan esto: un hook useFitText casero, un bucle de ResizeObserver o una librería como fitty, textFit o react-textfit.

Este artículo muestra una alternativa totalmente funcional con cero JavaScript: una fuente que escala a su contenedor usando unidades de container query de CSS, más un salto de línea correcto con guiones. Sin hooks, sin mediciones, sin salto de layout tras la hidratación.

El JavaScript que todos escriben

El patrón es siempre el mismo. Renderizar el texto en un tamaño base, leer el ancho del texto y el de su padre, y si el texto es más ancho, reducir el tamaño de fuente y volver a intentarlo. Una versión mínima se ve así:

useFitText.ts
import { useLayoutEffect, useRef, useState } from "react";

// Shrinks font-size by 1px until the text fits its parent.
export function useFitText() {
  const parentRef = useRef<HTMLElement>(null);
  const textRef = useRef<HTMLElement>(null);
  const [fontSize, setFontSize] = useState<number>();

  useLayoutEffect(() => {
    const parent = parentRef.current;
    const text = textRef.current;
    if (!parent || !text) return;

    const current = parseFloat(getComputedStyle(text).fontSize);
    if (parent.offsetWidth < text.offsetWidth) {
      setFontSize(current - 1); // re-render, measure again
    }
  }, [fontSize]);

  return { parentRef, textRef, fontSize };
}

Funciona, pero tiene costos reales:

  • Se ejecuta después de montar el componente, así que el texto se pinta primero con el tamaño equivocado y luego salta — un salto de layout visible y un golpe al CLS.
  • En frameworks con renderizado en servidor, el servidor envía un tamaño y el cliente lo corrige tras la hidratación, lo que puede provocar un parpadeo.
  • Envía JavaScript para algo que, en el fondo, es una cuestión de layout.
  • El bucle de 1px en 1px provoca varios re-renders y fuerza lecturas de layout síncronas, justo el tipo de trabajo que no quieres en el hilo principal.

Por qué JavaScript era la respuesta — hasta hace poco

Conviene ser honestos sobre por qué existen estos hooks. Hasta que las container queries llegaron a los navegadores, CSS simplemente no podía dimensionar una fuente en relación con un elemento. Podías dimensionar en relación con el viewport con vw, pero un titular dentro de una barra lateral de 400px y el mismo titular dentro de un hero de 1200px obtendrían el mismo tamaño basado en vw, porque vw no sabe nada del contenedor.

Las unidades de longitud de container query cambiaron eso. Solo se volvieron seguras entre navegadores alrededor de 2023, así que cualquier código de ajuste de fuente escrito antes no tenía opción en CSS y recurría a JavaScript por necesidad. Esa restricción ya no existe — y de eso trata este artículo.

La pieza clave: unidades de container query

Una unidad de longitud de container query es un porcentaje del tamaño de un contenedor de consulta. Marca un elemento como contenedor con container-type: inline-size y sus descendientes podrán usar cqi — donde 1cqi equivale al 1% del tamaño inline del contenedor (su ancho en modos de escritura horizontales).

.card {
  container-type: inline-size; /* this element is now a query container */
}

.card .title {
  /* 10% of .card's width — scales with the container, not the viewport */
  font-size: 10cqi;
}

Ahora el mismo título dentro de una tarjeta de 400px es de 40px, y dentro de una de 1200px es de 120px — automáticamente, sin medir. Esta es la pieza que faltaba antes de las container queries: un tamaño de fuente que responde al padre.

Acotar el tamaño con clamp()

El cqi puro seguiría creciendo en contenedores enormes y encogiéndose hasta nada en los diminutos. Envuélvelo en clamp() para fijar un suelo y un techo — normalmente tu tamaño móvil como mínimo y tu tamaño de escritorio como máximo:

.card {
  container-type: inline-size;
}

.card .title {
  font-size: clamp(
    2rem,   /* min — never smaller than the mobile size */
    10cqi,  /* fluid — scales with the container width   */
    4.5rem  /* max — never larger than the desktop size  */
  );
}

Léelo así: sé fluido con el contenedor, pero nunca por debajo de 2rem ni por encima de 4.5rem. El término del medio (la pendiente del cqi) controla la rapidez con que escala la fuente; los dos límites son los tamaños de fuente móvil y de escritorio de tu diseño. Si esos tamaños viven en custom properties, puedes controlarlo todo desde ahí:

.title {
  --size-desktop: 72;
  --size-mobile: 32;
}

.title .fit {
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );
}

Una trampa que te costará una hora: la cascada

Aquí está la trampa que hace que la gente se rinda y concluya: "las container queries no funcionan". El contenedor se resuelve correctamente, la unidad es la correcta, y aun así la fuente nunca cambia de tamaño. La causa casi siempre es la cascada, no las container queries.

El síntoma es tramposamente engañoso. Tu fuente se queda clavada en su máximo en todos los contenedores, por estrecho que sea — que es exactamente cómo se comporta cqi cuando no encuentra contenedor y recurre al viewport. Así que vas a comprobar lo obvio: ¿está bien montado el contenedor? Confirmas que container-type: inline-size está ahí, recorres el DOM para verificar que el ancestro es de verdad el contenedor, lees el container-type calculado del elemento y dice inline-size, mides el ancho del contenedor y es correcto. Todo cuadra. Y la fuente sigue clavada. Estás mirando en el sitio equivocado.

La salida más rápida es dejar de fiarte de tu propia regla y demostrar la unidad de forma independiente. Mete en el mismo contenedor una sonda desechable con nada más que un valor cqi puro, y mídela:

// Inject a bare probe as a child of the same container:
const probe = document.createElement("span");
probe.style.fontSize = "10cqi";
container.append(probe);

getComputedStyle(probe).fontSize;
// 90px inside a 900px container, 36px inside 360px → cqi works fine.

probe.remove();

Si la sonda escala correctamente pero tu elemento real sigue clavado, ese es todo el diagnóstico: las container queries funcionan perfectamente, y algo más está sobrescribiendo tu font-size. Es la cascada — y el culpable suele ser un selector más amplio que olvidaste.

Los sistemas de diseño suelen fijar tipografía en elementos anidados con una regla como .title p, .title span { font-size: … }. Ese selector tiene una especificidad de (0,1,1). Si tu regla de ajuste apunta al mismo elemento con una sola clase — .fit { … } con (0,1,0) — la regla del sistema de diseño gana y clava el tamaño de fuente, así que tu clamp() queda anulado en silencio. Ambas pueden incluso vivir en la misma capa de cascada, así que las capas no te salvarán. Peor aún, esa regla más amplia probablemente era basada en el viewport, y por eso el tamaño parecía un fallback de viewport — literalmente estabas viendo ganar a otra regla.

El arreglo es dar a tu regla al menos la misma especificidad. Anidarla bajo la clase del contenedor es lo más sencillo: .title .fit es (0,2,0), que gana a .title p con (0,1,1):

/* Loses to `.title p { font-size: … }` — same layer, higher specificity */
.fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

/* Wins: (0,2,0) > (0,1,1) */
.title .fit { font-size: clamp(2rem, 10cqi, 4.5rem); }

Si tu tamaño de fuente de container query parece ignorarse, inspecciona el elemento y comprueba qué regla fija realmente <code>font-size</code>. Nueve de cada diez veces un selector más amplio del sistema de diseño está ganando la cascada — la container query en sí está bien.

El problema de la palabra larga — y por qué no es un fallo de container query

Hay algo que cqi realmente no puede hacer: escala el tamaño de fuente según el ancho del contenedor, no según la longitud de una cadena concreta. Una sola palabra indivisible aún puede ser más ancha que el contenedor al tamaño calculado. El alemán es el culpable clásico: una palabra como Fremdsprachenkenntnisse se desbordará alegremente de una caja estrecha incluso a un tamaño de fuente adecuado al contenedor.

Esto no es un bug del enfoque; es su límite honesto. El viejo hook de JavaScript resolvía este caso midiendo esa palabra exacta y encogiéndola más. En CSS lo resuelves de otra forma — dejas que la palabra se parta en vez de encoger todo el titular. Dos propiedades hacen el trabajo:

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);

  /* Break a too-long word instead of overflowing the box */
  overflow-wrap: break-word;

  /* Balance line lengths for nicer multi-line headings */
  text-wrap: balance;
}

overflow-wrap: break-word es la red de seguridad: si una palabra no cabe en una línea, el navegador la parte en lugar de dejarla desbordar. text-wrap: balance es el pulido — iguala las longitudes de línea para que un titular con salto no acabe con una sola palabra solitaria.

Partir con guion — automáticamente, por idioma

Partir una palabra por la mitad sin guion queda tosco. CSS puede insertar un guion de verdad en un punto lingüísticamente válido con hyphens: auto. El detalle que conviene entender: la separación silábica depende del idioma. El navegador usa el diccionario de separación del idioma del elemento, que lee del atributo lang.

.title .fit {
  font-size: clamp(2rem, 10cqi, 4.5rem);
  hyphens: auto;              /* insert a real hyphen at valid break points */
  overflow-wrap: break-word;  /* last-resort break for words with no valid point */
  text-wrap: balance;
}

Lo elegante: si tu documento ya fija el idioma en la raíz — <html lang="de"> para una página en alemán — la separación silábica simplemente funciona, por idioma, sin cableado extra. Las páginas en alemán se separan con el diccionario alemán, las inglesas con el inglés. Obtienes guiones correctos en todos los idiomas gratis, mientras lang refleje el contenido.

Mantén overflow-wrap: break-word a su lado. hyphens: auto maneja las palabras que sabe separar; el fallback captura todo lo demás (URLs, nombres de marca, compuestos inventados) para que nada se desborde nunca.

Poniéndolo todo junto

Aquí está el componente completo, sin dependencias. Fíjate en la estructura de dos elementos: un elemento exterior que es el contenedor, y uno interior que lee cqi de él. Un elemento no puede dimensionar su propia fuente desde su propio contenedor, así que necesitas la división padre/hijo.

<h2 class="title" style="--size-desktop: 72; --size-mobile: 32">
  <span class="fit">Learn anything, beautifully</span>
</h2>
title.css
.title {
  /* The container the inner text scales against */
  container-type: inline-size;
}

/* Nested selector so it out-specifies design-system typography rules */
.title .fit {
  display: block;

  /* Fluid between the mobile and desktop sizes, driven by container width */
  font-size: clamp(
    calc(var(--size-mobile) * 1px),
    10cqi,
    calc(var(--size-desktop) * 1px)
  );

  /* Never overflow: break long words, hyphenate by language, balance lines */
  overflow-wrap: break-word;
  hyphens: auto;
  text-wrap: balance;
}

Eso es toda la funcionalidad: un titular que escala con su contenedor, respeta un suelo móvil y un techo de escritorio, nunca se desborda y se parte con guiones correctos en cualquier idioma — y no envía ni un solo byte de JavaScript.

CSS frente a JavaScript, con honestidad

AspectoHook / librería JS de fit-textCSS puro (cqi + clamp)
Responde al ancho del contenedorSí (mediante medición)Sí (mediante cqi)
Responde a la longitud exacta de la cadenaNo — acotado con overflow-wrap / hyphens en su lugar
Salto de layout tras la cargaComún (medir y luego redimensionar)Ninguno — correcto en el primer paint
Funciona en SSR antes de la hidrataciónNo
JavaScript enviadoNinguno
Coste en el hilo principalReflows + re-rendersCero
Separación silábica por idiomaManualIntegrada vía hyphens: auto

La única columna en la que JavaScript aún gana es el ajuste exacto por cadena — forzar un titular concreto a una sola línea al mayor tamaño que quepa. Si ese comportamiento preciso es un requisito estricto, la medición (JS, o un <text> SVG que escala a un viewBox) sigue siendo la única vía. Pero para la inmensa mayoría de los titulares, el dimensionado relativo al contenedor se ve igual de bien o mejor, sin ninguno de esos costes.

Compatibilidad de navegadores

Las unidades de longitud de container query (cqi y afines) están soportadas en todos los navegadores evergreen actuales, y lo están desde 2023. clamp(), overflow-wrap, hyphens y text-wrap: balance también están ampliamente disponibles (text-wrap: balance es el más nuevo, degradándose con elegancia al salto normal donde no existe). Para un fallback en navegadores muy antiguos, basta con un font-size simple declarado antes de la línea del clamp().

Conclusiones

  • El JavaScript de ajuste de fuente que todos escriben existe sobre todo porque CSS no podía dimensionar la tipografía por contenedor hasta que llegaron las container queries.
  • container-type: inline-size + font-size: clamp(min, Ncqi, max) da tipografía relativa al contenedor con un suelo móvil y un techo de escritorio.
  • Si el tamaño parece ignorarse, es la cascada — supera la especificidad de selectores amplios del sistema de diseño anidando tu regla.
  • cqi escala por ancho de contenedor, no por longitud de cadena; usa overflow-wrap: break-word y hyphens: auto para que las palabras largas se partan limpiamente en vez de desbordarse.
  • hyphens: auto depende del idioma gratis cuando el lang del documento está bien puesto.
  • Recurre a JavaScript solo cuando de verdad necesites un ajuste exacto de una línea por cadena.

Ya está publicado — dos paquetes diminutos y sin dependencias: @oleksiimazurenko/react-fit-text para React y @oleksiimazurenko/fit-text para el núcleo CSS agnóstico del framework (código en GitHub):

npm install @oleksiimazurenko/react-fit-text
import { FitText } from '@oleksiimazurenko/react-fit-text'
import '@oleksiimazurenko/fit-text/style.css'

// No props needed — scales to its container with sensible defaults.
<FitText>Learn anything, beautifully</FitText>